Compare commits

...

132 Commits

Author SHA1 Message Date
Carlos Valente 03c9f51b47 chore: update demo project 2025-10-17 12:20:18 +02:00
Carlos Valente 8d00fd3966 bump version to 4.0.1 2025-10-17 12:20:18 +02:00
Carlos Valente 80fd21354e fix: apply styling to overtime timer 2025-10-16 21:13:26 +02:00
Carlos Valente a87b15825a fix: end message scaling 2025-10-16 21:13:26 +02:00
Carlos Valente 6a8f98c469 fix: end message only shows if timer is in overtime 2025-10-16 21:13:26 +02:00
jwetzell 8685298430 Merge pull request #1830 from cpvalente/fix/linux-project-download 2025-10-15 07:25:57 -05:00
jwetzell d43af2db57 pass options to project download to allow folders beginning with a dot 2025-10-14 19:51:15 -05:00
jwetzell ef718c48e4 Merge pull request #1828 from cpvalente/npm-trusted-publisher
update cli and resolver workflows for new NPM auth
2025-10-14 16:18:53 -05:00
Alex Christoffer Rasmussen 45e7373279 Small clean-up (#1827)
* chore: avoid shadow variable name

* chore: map could just be foreach

* clean uneeded template literal

* lint
2025-10-13 20:24:25 +02:00
jwetzell 6af2d6e021 update cli and resolver workflows for new NPM auth 2025-10-12 14:11:52 -05:00
Alex Christoffer Rasmussen 9b36f36f73 fix: next cue (#1821)
* fix next cue

* Update apps/server/src/services/runtime-service/runtime.service.ts

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-10-12 14:42:55 +02:00
Carlos Valente 3a0dbf057e chore: update docs 2025-10-12 07:48:25 +02:00
Carlos Valente dae84ab48a chore: update screenshots 2025-10-12 07:48:25 +02:00
Carlos Valente aa927597f3 fix: product name ontime 2025-10-10 19:03:53 +02:00
Carlos Valente ef7f0fb54a refactor: bump modal sizes 2025-10-10 18:32:29 +02:00
Carlos Valente 324320fdc8 fix: custom field is multiline 2025-10-10 18:32:29 +02:00
Carlos Valente 4b27d080b6 fix: background colour in not found page 2025-10-10 18:13:58 +02:00
Carlos Valente 7918063753 bump version to 4.0.0 2025-10-10 18:13:58 +02:00
arc-alex e2202c2417 fix: dynamic full hight for mobile view param editor 2025-10-10 18:07:39 +02:00
Carlos Valente 9817c8f8c0 fix: selected styling overrides 2025-10-10 17:41:26 +02:00
Carlos Valente 1d8c04d0b4 feat: export excel file
refactor: align id columns between export and import

refactor: match column names with template
2025-10-10 17:41:26 +02:00
Carlos Valente 10762f10bf docs: caveat on proxy not working before project build 2025-10-10 17:41:26 +02:00
Alex Christoffer Rasmussen da7c0d4ff2 Small clean-up (#1815)
* chore: remove old comment

* chore: smal lclean up of a e2e test

* chore: bump to beta.6

* chore: cleanup imports

* fix integration values com in ms

* allow linkStart and timeStrategy from API

* remove local mac dist
2025-10-10 17:37:55 +02:00
Carlos Valente 4a27973de1 refactor: allow locking op settings and navigation 2025-10-10 16:01:41 +02:00
Carlos Valente bdeb087e28 fix: op gets params from preset context 2025-10-10 16:01:41 +02:00
Carlos Valente 3662cd58f5 refactor: extract operator data loading 2025-10-10 16:01:41 +02:00
Carlos Valente 62c52bca07 refactor: larger secondary field in timer 2025-10-09 21:03:22 +02:00
Carlos Valente 1fc4a06133 fix: allow user to reset schedule filter 2025-10-09 21:03:22 +02:00
Alex Christoffer Rasmussen 147862e647 Fix opview with groups (#1814)
* create failing test

* fix: op evet used past state of the group
2025-10-08 21:19:57 +02:00
Alex Christoffer Rasmussen 12bc323e57 Fix build (#1813)
* fix resolver build

* fix docker build

* node version in docker via arg

* add step to setup auth for pnpm

* lint

---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
2025-10-08 10:44:53 +02:00
Alex Christoffer Rasmussen de8f9ef587 Merge pull request #1535 from cpvalente/v4
v4
2025-10-07 12:51:09 +02:00
arc-alex 217402050d fix merge issue 2025-10-07 12:44:06 +02:00
Alex Christoffer Rasmussen de75d55849 Merge branch 'master' into v4 2025-10-07 12:39:06 +02:00
Alex Christoffer Rasmussen c4e6215b29 Update test time until (#1809)
* test views

test timeline

* timeline show seconds when less than 2m

* fix: countown typings

* refactor: backstage schedule item

* chore: lint
2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 57315595a5 All views follow offset mode (#1808)
* chore: remove unused

* fix: studio view follow offset mode
2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen fc7ed6bd20 clean turbo config and cache github actions (#1805)
* refactor: turbo scripts

refactor dev and build scripts

remove cleanup

* fix e2e script

* cache playwright

* cache pnpm

* fix docker env in vite

* fixup! refactor: turbo scripts

* chore: add comment
2025-10-07 12:32:12 +02:00
jwetzell 750927dc8d chore: switch workspaces to use pnpm to setup node with useNodeVersion value 2025-10-07 12:32:12 +02:00
Carlos Valente 00d2ae0a7c refactor: clarify settings section name 2025-10-07 12:32:12 +02:00
Carlos Valente 56c4d68bae refactor: use coffee icon for keep awake feature 2025-10-07 12:32:12 +02:00
Carlos Valente a02cdf71c0 fix: maintain offset on roll takeover 2025-10-07 12:32:12 +02:00
Carlos Valente 25030debe4 refactor: error page sends email 2025-10-07 12:32:12 +02:00
Carlos Valente 27243c4432 chore: upgrade electron packages 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 22559c59d4 Expected time for views (#1798)
* refactor: use correct metadata in op view

* refactor: use correct metadata in backstage view

* remove uneeded test

* fix: correct offset colour

* refactor: use correct metadata in timline view

* refactor: schedule item dont pass the whole event

* chore: lint
2025-10-07 12:32:12 +02:00
Carlos Valente 700948bfbb fix: use correct order when creating groups 2025-10-07 12:32:12 +02:00
Carlos Valente 960a39fbf4 refactor: cleanup dragging cursor indicator 2025-10-07 12:32:12 +02:00
Carlos Valente d986f438d9 refactor: improve delay component 2025-10-07 12:32:12 +02:00
Carlos Valente c2012bd9e3 refactor: improve studio clock responsiveness 2025-10-07 12:32:12 +02:00
Carlos Valente 1f0f92371f fix: show secondary timer only if active 2025-10-07 12:32:12 +02:00
Carlos Valente 4b81725e80 chore: upgrade base-ui 2025-10-07 12:32:12 +02:00
Carlos Valente 846eaf569d refactor: remove project CSV export option 2025-10-07 12:32:12 +02:00
Carlos Valente ccf1ddf2fd fix: conflict between swatch picker and swatch elements 2025-10-07 12:32:12 +02:00
Carlos Valente 81e00993f2 fix: prevent rolling into events of 0 duration 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen e22c7698c3 fix: alpha bg (#1799) 2025-10-07 12:32:12 +02:00
arc-alex ef053fc2f7 bump to v4.0.0-beta.5 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 3b6d3a4631 feat: Keep screen awake (#1788)
* feat keepAwake hook

* reaqure lock after view change

* refactor: move it to a navigation view setting

* save to search params
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 31c3e603ed refactor: better type for api aux action (#1796) 2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 1dfe5c980d refactor: collect common tsconfig (#1794)
* refactor: collect common tsconfig settings

* chore: module and target should be lower case
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 80e4dc0941 Fix: rundown import (#1795)
* fix: patch in the active rundown id and title

* fix: send a ALL refetch on project patch

* fix: load the last used rundown on project patch and not just the first one

* chore: lint

* chore: clean up and let rundown service send the refetch request

* chore: dont destructure rundown
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen bc0b83aa14 remove private field from package (#1793) 2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 971c6c66f0 Chore: Small todo about coerce boolean (#1789)
* fix: coerce boolean types negative number is false

* strict coerce
2025-09-26 21:34:01 +02:00
Carlos Valente 343697dc93 refactor: remove unused dependencies 2025-09-26 21:34:01 +02:00
Carlos Valente 4058231d6b refactor: remove unused code 2025-09-26 21:34:01 +02:00
Carlos Valente c5ff254267 refactor: prevent upload logo errors from being stuck 2025-09-26 21:34:01 +02:00
Carlos Valente ac14cb9624 refactor: increase file size limits 2025-09-26 21:34:01 +02:00
Carlos Valente bcabc50ad1 fix: check automation usage in all rundowns before allowing delete 2025-09-26 21:34:01 +02:00
Carlos Valente cbd7bce106 bump version to 4.0.0-beta.4 2025-09-26 21:34:01 +02:00
Carlos Valente 20859abda9 chore: add demo images to github 2025-08-09 07:01:01 +02:00
Alex Christoffer Rasmussen 1025347c08 refactor: add version to session endpoint (#1659) 2025-06-23 20:42:53 +02:00
Carlos Valente 43988ea12b fix: stale ids in cuesheet (#1657) 2025-06-23 19:56:53 +02:00
Alex Christoffer Rasmussen 722e045b20 Revert "Session version" (#1649)
* Revert "refactor: add version to session endpoint"

This reverts commit cd40a6e55e.

* Revert "Remove public event feature (#1645)"

This reverts commit 08d9e24871.

* Revert "Fix: rearrange playing event (#1640)"

This reverts commit 0649678dca.

* Revert "refactor: style tweaks to edit css modal"

This reverts commit a00ec2d02a.

* Revert "refactor: remove usages of framer-motion"

This reverts commit 54a74ccc2a.

* Revert "Upgrade expressjs (#1633)"

This reverts commit 6f3ab274bd.

* Revert "Refactor: require trigger in all events objects (#1636)"

This reverts commit 90870ecfb6.

* Revert "Fix: Correct boundary condition in applyDelay"

This reverts commit b640e0e181.

* Revert "let vite be the proxy to the dev server (#1630)"

This reverts commit c41fe824cf.

* Revert "refactor: migrate custom fields to transactions"

This reverts commit 62c8319d70.

* Revert "refactor: simplify validations"

This reverts commit b1d23467a2.

* Revert "refactor: create transaction system and apply to adding entry (#1620)"

This reverts commit 9a62daf047.

* Revert "Refactor: better rounding (#1594)"

This reverts commit b9ab1c6fd7.

* Revert "refactor: improve reorder logic"

This reverts commit c1054711b0.

* Revert "chore: simplify URLs"

This reverts commit a4d4f29a37.

* Revert "refactor: order is single source of truth"

This reverts commit 2793aadea0.

* Revert "refactor: refetch targets is enum"

This reverts commit e7cfb7d9d9.

* Revert "refactor: small ux improvements"

This reverts commit 256a851c9b.

* Revert "refactor: remove trivially inferred numEvents"

This reverts commit 4ab9c81cb8.

* Revert "feat: duplicate groups"

This reverts commit 2f13d6c89e.

* Revert "feat: create group from entry selection"

This reverts commit f1f7bad25e.

* Revert "feat: create block from rundown empty"

This reverts commit a8b52a48f7.

* Revert "fix: collapsed blocks dont render children"

This reverts commit cd0999b2ab.

* Revert "refactor: type cleanup and test improvements"

This reverts commit b4c60f3f04.

* Revert "feat: allow dissolving a block"

This reverts commit 5cefad3666.

* Revert "fix: uncontrolled prop on controlled component"

This reverts commit 7a6ecd8c34.

* Revert "refactor: improve return of reorder"

This reverts commit ba96ecfd91.

* Revert "refactor: mutations on batch elements must have IDs"

This reverts commit 7bed3757f2.

* Revert "chore: upgrade dependencies"

This reverts commit e2e755b1d2.

* Revert "refactor: extract utility to merge two arrays"

This reverts commit a77d23109d.

* Revert "refactor: change network mode defaults"

This reverts commit 0eb3b8d382.

* Revert "fix: delete nested events"

This reverts commit 0021185288.

* Revert "fix: add event at end of block"

This reverts commit 94c72ff4f6.

* Revert "refactor: make finder available in exported rundown"

This reverts commit e4c08dc9b2.

* Revert "assert non null and update test (#1604)"

This reverts commit ec74af0d62.

* Revert "Fix project renumber (#1597)"

This reverts commit b6d72dd082.

* Revert "Refactor: WebSocket from flush queue to one patch (#1595)"

This reverts commit 31c311daf0.

* Revert "Refactor: ms for api calls (#1593)"

This reverts commit e9b3cc6090.

* Revert "fix test (#1601)"

This reverts commit 543b04a097.

* Revert "fix: rebase master"

This reverts commit d39b85b6e6.

* Revert "chore: correct test path"

This reverts commit bbe107bb2b.

* Revert "refactor: extract rundown parsing"

This reverts commit c616240db1.

* Revert "chore: improve convention entry <> event"

This reverts commit 166be66ce3.

* Revert "refactor: maintain flat orders"

This reverts commit 4180d0a337.

* Revert "refactor: implement operations on nested events"

This reverts commit 78108e316c.

* Revert "refactor: process events in rundown"

This reverts commit 3bb8b70915.

* Revert "chore: improve convention entry <> event"

This reverts commit 1c4f13a0ed.

* Revert "chore: rename currentBlock > parent"

This reverts commit 3ca0abad53.

* Revert "refactor: fix delay positioning in gaps"

This reverts commit 030c8f897f.

* Revert "refactor(e2e): skip flaky test"

This reverts commit 68175cfa3b.

* Revert "refactor: improve project loading"

This reverts commit fd8f757851.

* Revert "refactor: gather group metadata"

This reverts commit 876d111c61.

* Revert "refactor: swap maintains schedule"

This reverts commit 730cb95c04.

* Revert "chore: rename files"

This reverts commit 3c388d4fb5.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 2e23718d73.

* Revert "refactor: clearer relationship on rundown elements"

This reverts commit 89ea8c470b.

* Revert "refactor: use strict typing"

This reverts commit 178640bfc4.

* Revert "refactor: remove stop as a possible end action"

This reverts commit 4ed38340e0.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 69eb9a5eff.

* Revert "chore: remove IDE files"

This reverts commit 351425127a.

* Revert "refactor: remove unused and legacy code"

This reverts commit 95f2ba37cc.
2025-06-20 12:28:20 +02:00
Carlos Valente ad6804019b bump version to 3.16.0 2025-06-20 07:41:39 +02:00
Carlos Valente cd40a6e55e refactor: add version to session endpoint 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 08d9e24871 Remove public event feature (#1645) 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 0649678dca Fix: rearrange playing event (#1640)
* creat failing test

* find and pass index of eventNow in case it moved

* fix cache invalidate edits should also recalculate timer

* use coluor as test indicator

* fixed empty cue guard

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
google-labs-jules[bot] a00ec2d02a refactor: style tweaks to edit css modal 2025-06-19 18:53:28 +02:00
google-labs-jules[bot] 54a74ccc2a refactor: remove usages of framer-motion 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 6f3ab274bd Upgrade expressjs (#1633)
* upgrade expressjs

* migration

* reenable test

* extend timeout on download test

* fixup! migration

* move empty body test from controller to validator

* enusre not empty

* extract validation function

* fixup! reenable test

* remove thin controllers

* disable e2e test of project file download
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 90870ecfb6 Refactor: require trigger in all events objects (#1636)
* reqire trigger in all events

* use structuredClone when cloning an event
2025-06-19 18:53:28 +02:00
google-labs-jules[bot] b640e0e181 Fix: Correct boundary condition in applyDelay
The `applyDelay` function had a condition that incorrectly used `rundown.order.length` instead of `rundownMetadata.flatEntryOrder.length` to check if a delay entry was the last in the sequence. `rundown.order` only contains top-level entries, while `flatEntryOrder` contains all entries, including those within blocks, which is the relevant list for this check.

This commit corrects the condition to use `rundownMetadata.flatEntryOrder.length`.

Existing tests in `rundown.dao.test.ts` (specifically the test `removes delays in last position without applying changes`) already cover this scenario and pass with the correction, ensuring the fix behaves as expected.
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen c41fe824cf let vite be the proxy to the dev server (#1630) 2025-06-19 18:53:28 +02:00
Carlos Valente 62c8319d70 refactor: migrate custom fields to transactions
refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
2025-06-19 18:53:28 +02:00
Carlos Valente b1d23467a2 refactor: simplify validations 2025-06-19 18:53:28 +02:00
Carlos Valente 9a62daf047 refactor: create transaction system and apply to adding entry (#1620)
* refactor: create transaction system and apply to adding entry

* refactor: migrate edit mutations to transaction

* refactor: migrate delete mutation to transaction

* refactor: migrate reorder mutation to transaction

* refactor: migrate apply delay to transaction

* refactor: migrate swapEvents to transaction

* refactor: migrate clone to transaction

* refactor: migrate group/ungroup transactions

* refactor: simplify mutations

* refactor: migrate getters

* chore: add tests to processRundown()
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen b9ab1c6fd7 Refactor: better rounding (#1594) 2025-06-19 18:53:28 +02:00
Carlos Valente c1054711b0 refactor: improve reorder logic 2025-06-19 18:53:28 +02:00
Carlos Valente a4d4f29a37 chore: simplify URLs 2025-06-19 18:53:28 +02:00
Carlos Valente 2793aadea0 refactor: order is single source of truth 2025-06-19 18:53:28 +02:00
Carlos Valente e7cfb7d9d9 refactor: refetch targets is enum 2025-06-19 18:53:28 +02:00
Carlos Valente 256a851c9b refactor: small ux improvements
- rename dissolve > ungroup
- prevent ondrag when clicking
- add untitled as block title fallback
- move block action to context menu
2025-06-19 18:53:28 +02:00
Carlos Valente 4ab9c81cb8 refactor: remove trivially inferred numEvents 2025-06-19 18:53:28 +02:00
Carlos Valente 2f13d6c89e feat: duplicate groups 2025-06-19 18:53:28 +02:00
Carlos Valente f1f7bad25e feat: create group from entry selection 2025-06-19 18:53:28 +02:00
Carlos Valente a8b52a48f7 feat: create block from rundown empty 2025-06-19 18:53:28 +02:00
Carlos Valente cd0999b2ab fix: collapsed blocks dont render children 2025-06-19 18:53:28 +02:00
Carlos Valente b4c60f3f04 refactor: type cleanup and test improvements 2025-06-19 18:53:28 +02:00
Carlos Valente 5cefad3666 feat: allow dissolving a block 2025-06-19 18:53:28 +02:00
Carlos Valente 7a6ecd8c34 fix: uncontrolled prop on controlled component 2025-06-19 18:53:28 +02:00
Carlos Valente ba96ecfd91 refactor: improve return of reorder 2025-06-19 18:53:28 +02:00
Carlos Valente 7bed3757f2 refactor: mutations on batch elements must have IDs 2025-06-19 18:53:28 +02:00
Carlos Valente e2e755b1d2 chore: upgrade dependencies 2025-06-19 18:53:28 +02:00
Carlos Valente a77d23109d refactor: extract utility to merge two arrays 2025-06-19 18:53:28 +02:00
Carlos Valente 0eb3b8d382 refactor: change network mode defaults 2025-06-19 18:53:28 +02:00
Carlos Valente 0021185288 fix: delete nested events 2025-06-19 18:53:28 +02:00
Carlos Valente 94c72ff4f6 fix: add event at end of block 2025-06-19 18:53:28 +02:00
Carlos Valente e4c08dc9b2 refactor: make finder available in exported rundown 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen ec74af0d62 assert non null and update test (#1604) 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen b6d72dd082 Fix project renumber (#1597)
* fix: generateUniqueFileName

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 31c311daf0 Refactor: WebSocket from flush queue to one patch (#1595)
* change flush to one patch

* remove unused types

* create batch

* merge patch into eventStore

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen e9b3cc6090 Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 543b04a097 fix test (#1601)
* fix test

* add comment
2025-06-19 18:53:28 +02:00
Carlos Valente d39b85b6e6 fix: rebase master 2025-06-19 18:53:28 +02:00
Carlos Valente bbe107bb2b chore: correct test path 2025-06-19 18:53:28 +02:00
Carlos Valente c616240db1 refactor: extract rundown parsing
refactor: implement groups in editor
2025-06-19 18:53:28 +02:00
Carlos Valente 166be66ce3 chore: improve convention entry <> event 2025-06-19 18:53:28 +02:00
Carlos Valente 4180d0a337 refactor: maintain flat orders 2025-06-19 18:53:28 +02:00
Carlos Valente 78108e316c refactor: implement operations on nested events 2025-06-19 18:53:28 +02:00
Carlos Valente 3bb8b70915 refactor: process events in rundown 2025-06-19 18:53:28 +02:00
Carlos Valente 1c4f13a0ed chore: improve convention entry <> event 2025-06-19 18:53:28 +02:00
Carlos Valente 3ca0abad53 chore: rename currentBlock > parent 2025-06-19 18:53:28 +02:00
Carlos Valente 030c8f897f refactor: fix delay positioning in gaps 2025-06-19 18:53:28 +02:00
Carlos Valente 68175cfa3b refactor(e2e): skip flaky test 2025-06-19 18:53:28 +02:00
Carlos Valente fd8f757851 refactor: improve project loading 2025-06-19 18:53:28 +02:00
Carlos Valente 876d111c61 refactor: gather group metadata 2025-06-19 18:53:28 +02:00
Carlos Valente 730cb95c04 refactor: swap maintains schedule 2025-06-19 18:53:28 +02:00
Carlos Valente 3c388d4fb5 chore: rename files 2025-06-19 18:53:28 +02:00
Carlos Valente 2e23718d73 refactor: restructure model to contain an object of rundowns 2025-06-19 18:53:28 +02:00
Carlos Valente 89ea8c470b refactor: clearer relationship on rundown elements 2025-06-19 18:53:28 +02:00
Carlos Valente 178640bfc4 refactor: use strict typing 2025-06-19 18:53:28 +02:00
Carlos Valente 4ed38340e0 refactor: remove stop as a possible end action 2025-06-19 18:53:28 +02:00
Carlos Valente 69eb9a5eff refactor: restructure model to contain an object of rundowns 2025-06-19 18:53:28 +02:00
Carlos Valente 351425127a chore: remove IDE files 2025-06-19 18:53:28 +02:00
Carlos Valente 95f2ba37cc refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-06-19 18:53:28 +02:00
176 changed files with 2255 additions and 1658 deletions
-1
View File
@@ -17,7 +17,6 @@
# Ignore build folders
node_modules
**/node_modules
**/dist
# Ignore default volumes created by running docker compose up
ontime-db
+2 -1
View File
@@ -1 +1,2 @@
"ONTIME_VERSION.js"
"ONTIME_VERSION.js"
dist/
Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 149 KiB

+17 -14
View File
@@ -14,19 +14,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -38,16 +38,19 @@ jobs:
- name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client
run: cp -R apps/client/build apps/cli/client
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli
+49 -29
View File
@@ -6,43 +6,63 @@ on:
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
@@ -2,7 +2,7 @@ name: Ontime build
on:
push:
tags: [ "*" ]
tags: ['*']
workflow_dispatch:
jobs:
@@ -11,15 +11,20 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -30,7 +35,7 @@ jobs:
run: pnpm build
- name: Electron - Build app
env:
env:
APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
@@ -53,15 +58,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -86,15 +85,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
+17 -14
View File
@@ -6,7 +6,7 @@ on:
workflow_dispatch:
jobs:
build_cli:
build_resolver:
permissions:
id-token: write
runs-on: ubuntu-latest
@@ -14,29 +14,32 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
run: pnpm turbo @getontime/resolver#build
run: pnpm build:resolver
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver
+41 -46
View File
@@ -14,55 +14,35 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
# Run code quality
- name: Run linter
run: pnpm lint
working-directory: ./packages/types
- name: Run TypeScript checks
run: pnpm typecheck
# We choose to run tests separately
- name: React - Run unit tests
- name: Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
run: pnpm test
e2e-test:
runs-on: ubuntu-latest
@@ -70,21 +50,36 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Get installed Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
- run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers
run: npx playwright install --with-deps
+6 -2
View File
@@ -5,6 +5,10 @@ node_modules
playwright-report
pnpm-lock.yaml
**/*.toml
**/*.yml
**/*.json
**/*.json
!tsconfig.common.json
!turbo.json
!package.json
+9 -11
View File
@@ -21,7 +21,8 @@ Locally, we would need to run both the React client and the node.js server in de
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `pnpm turbo dev`
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
### Debugging backend
@@ -30,10 +31,10 @@ The previous command will start the development servers for both the client, ser
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
- __Run the React UI__ by running `pnpm dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm turbo dev:inspect --filter=ontime-server`.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`.
## TESTING
@@ -46,7 +47,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `pnpm turbo test:pipeline` from the project root.
You can run unit tests by running `pnpm test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -66,7 +67,7 @@ start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e:ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -77,14 +78,11 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `pnpm turbo run build:electron`
- __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
- __Build the UI and server__ by running `pnpm build`
- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux`
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images.
+30 -37
View File
@@ -1,37 +1,30 @@
FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:22-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
WORKDIR /app/
# Prepare UI
COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
ARG NODE_VERSION=22.15.1
FROM node:${NODE_VERSION}-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
RUN mkdir /app
WORKDIR /app/
# Prepare UI
COPY apps/client/build/ ./client/
# Prepare Backend
COPY apps/server/dist/ ./server/
COPY apps/server/src/external/ ./external/
COPY apps/server/src/user/ ./user/
COPY apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# pnpm build:docker
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
-2
View File
@@ -26,8 +26,6 @@ We do our best to have most topics covered by the documentation. However, if you
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-beta.3",
"version": "4.0.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
-6
View File
@@ -13,12 +13,6 @@
<link rel="manifest" href="/manifest.json" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<style>
body,
html {
background-color: #101010 !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+6 -9
View File
@@ -1,10 +1,10 @@
{
"name": "ontime-ui",
"version": "4.0.0-beta.3",
"version": "4.0.1",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.3",
"@base-ui-components/react": "1.0.0-beta.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -16,7 +16,7 @@
"@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.11.0",
"axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
@@ -37,15 +37,12 @@
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion",
"dev": "cross-env BROWSER=none vite",
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"dev:electron": "pnpm dev",
"lint": "eslint . --quiet",
"typecheck": "tsc --noEmit",
"build": "vite build",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
},
"browserslist": {
+2
View File
@@ -8,6 +8,7 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket';
import KeepAwake from './features/keep-awake/KeepAwake';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
@@ -24,6 +25,7 @@ function App() {
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<KeepAwake />
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
+4 -3
View File
@@ -102,6 +102,7 @@ export default function AppRouter() {
path='op'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator />
</ViewLoader>
}
@@ -207,13 +208,13 @@ function PresetView() {
/**
* Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param
*
* We inject the preset to the context value for the view to consume
*/
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
{preset.target !== OntimeView.Cuesheet && (
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
-24
View File
@@ -1,9 +1,6 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -33,27 +30,6 @@ export async function downloadProject(fileName: string) {
}
}
/**
* Request download of the current rundown as a CSV file
* @param fileName
*/
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
} catch (error) {
console.error(error);
}
}
/**
* HTTP request to upload project file
*/
+16
View File
@@ -3,6 +3,7 @@ import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -39,3 +40,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
});
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
responseType: 'blob',
});
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
} catch (error) {
console.error('Error downloading file:', error);
}
}
@@ -1,6 +1,5 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { IoCheckmark, IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
@@ -49,15 +49,14 @@ class ErrorBoundary extends React.Component {
return (
<div className={style.errorContainer} data-testid='error-container'>
<div>
<p className={style.error}>:/</p>
<p>Something went wrong</p>
<div
role='button'
<p className={style.error}>: /</p>
<p>Something went wrong.</p>
<a
className={style.report}
onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })}
href={`mailto:mail@getontime.no?subject=Error%20Report&body=${encodeURIComponent(this.reportContent)}`}
>
Report error
</div>
</a>
<div
role='button'
className={style.report}
@@ -3,24 +3,21 @@
height: 100%;
display: grid;
place-content: center;
background-color: #121212;
color: white;
background-color: $ui-black;
color: $ui-white;
}
.error {
color: $error-red;
font-weight: 600;
}
.error {
color: $error-red;
}
.report {
text-decoration: underline $error-red;
cursor: pointer;
}
.report {
color: $blue-500;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
.report:hover {
color: $error-red;
}
.report:active {
color: white;
&:hover {
color: $ontime-color;
}
}
@@ -1,3 +1,4 @@
import { MouseEvent } from 'react';
import { IoBan } from 'react-icons/io5';
import { cx } from '../../../utils/styleUtils';
@@ -10,12 +11,13 @@ interface SwatchProps {
isSelected?: boolean;
}
export default function Swatch(props: SwatchProps) {
const { color, isSelected, onClick } = props;
const handleClick = () => {
export default function Swatch({ color, isSelected, onClick }: SwatchProps) {
const handleClick = (event: MouseEvent) => {
onClick?.(color);
event.preventDefault();
event.stopPropagation();
};
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) {
@@ -20,7 +20,7 @@ export default function DelayInput(props: DelayInputProps) {
const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
// avoid wrong submit on cancel
let ignoreChange = false;
const ignoreChangeRef = useRef(false);
// set internal value on duration change
useEffect(() => {
@@ -35,8 +35,8 @@ export default function DelayInput(props: DelayInputProps) {
* @param {string} newValue string to be parsed
*/
const validateAndSubmit = (newValue: string) => {
if (ignoreChange) {
ignoreChange = false;
if (ignoreChangeRef.current) {
ignoreChangeRef.current = false;
return;
}
@@ -78,7 +78,7 @@ export default function DelayInput(props: DelayInputProps) {
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Escape') {
ignoreChange = true;
ignoreChangeRef.current = true;
setValue(millisToString(duration));
inputRef.current?.blur();
}
@@ -11,8 +11,7 @@ interface AppLinkProps {
* Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
export default function AppLink({ search, children }: PropsWithChildren<AppLinkProps>) {
const navigate = useNavigate();
const handleClick = () => navigate({ search });
@@ -12,9 +12,7 @@ interface ExternalLinkProps {
inline?: boolean;
}
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
@@ -5,9 +5,9 @@
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-width: min(880px, 90vw);
min-height: min(200px, 10vh);
max-width: min(900px, 90vw);
max-width: min(1200px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -1,10 +1,12 @@
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
import { navigatorConstants } from '../../../viewerConfig';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -31,6 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
return (
@@ -62,6 +65,13 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{window.isSecureContext && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<hr className={style.separator} />
@@ -1,5 +1,4 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils';
@@ -62,8 +62,6 @@
padding: 2px;
border-radius: $component-border-radius-md;
color: $ui-white;
overflow-y: auto;
max-height: 20rem;
border: 1px solid $gray-1000;
&[data-side='start'] {
@@ -73,6 +71,16 @@
}
}
.list {
box-sizing: border-box;
position: relative;
padding-block: 0.25rem;
overflow-y: auto;
max-height: 20rem;
max-height: var(--available-height);
scroll-padding-block: 1.5rem;
}
.item {
box-sizing: border-box;
outline: 0;
@@ -31,14 +31,17 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
<BaseSelect.Arrow />
<BaseSelect.List className={styles.list}>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.List>
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner>
@@ -30,6 +30,7 @@
width: 40rem;
max-width: 100vw;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
@@ -6,7 +6,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
@@ -54,8 +54,8 @@ export function useFlatRundown() {
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
}
@@ -85,8 +85,8 @@ export function useFlatRundownWithMetadata() {
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
const { data, status } = useFlatRundown();
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
+3 -20
View File
@@ -164,11 +164,6 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.offset.absolute,
}));
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
mode: state.offset.mode,
@@ -178,14 +173,6 @@ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
currentDay: state.eventNow?.dayOffset ?? 0,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.offset.absolute,
}));
export const usePing = createSelector((state: RuntimeStore) => ({
ping: state.ping,
}));
@@ -211,7 +198,7 @@ export const usePlayback = () => {
/* ======================= Overview data subscriptions ======================= */
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
@@ -289,11 +276,7 @@ export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
message: state.message,
time: state.timer,
offset: state.offset,
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
rundown: state.rundown,
}));
export const useTimelineSocket = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.offset.absolute,
expectedRundownEnd: state.offset.expectedRundownEnd,
}));
@@ -1 +0,0 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
@@ -1,89 +0,0 @@
import { create } from 'zustand';
type Target = 'cuesheet' | 'timer' | 'clock' | 'countdown' | 'backstage' | 'studio';
interface SelectionState {
[key: string]: boolean;
}
interface ColumnPermissions {
read: string[];
write: string[];
}
interface CuesheetLinksState {
target: Target | null;
readSelected: SelectionState;
writeSelected: SelectionState;
setTarget: (target: Target | null) => void;
setField: (field: 'read' | 'write', key: string, value: boolean) => void;
toggleField: (field: 'read' | 'write', key: string) => void;
selectAll: (field: 'read' | 'write', keys: string[]) => void;
clearAll: (field: 'read' | 'write', keys: string[]) => void;
// Returns arrays of column keys that have read/write permissions if target is 'cuesheet'
getSelections: () => ColumnPermissions | null;
}
export const useCuesheetLinksStore = create<CuesheetLinksState>((set, get) => ({
target: null,
readSelected: {},
writeSelected: {},
setTarget: (target) => set({ target }),
setField: (field, key, value) =>
set((state) => ({
...(field === 'read'
? { readSelected: { ...state.readSelected, [key]: value } }
: { writeSelected: { ...state.writeSelected, [key]: value } }),
})),
toggleField: (field, key) =>
set((state) => ({
...(field === 'read'
? { readSelected: { ...state.readSelected, [key]: !state.readSelected[key] } }
: { writeSelected: { ...state.writeSelected, [key]: !state.writeSelected[key] } }),
})),
selectAll: (field, keys) =>
set((_state) => ({
...(field === 'read'
? {
readSelected: keys.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as SelectionState),
}
: {
writeSelected: keys.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as SelectionState),
}),
})),
clearAll: (field, keys) =>
set((_state) => ({
...(field === 'read'
? {
readSelected: keys.reduce((acc, key) => {
acc[key] = false;
return acc;
}, {} as SelectionState),
}
: {
writeSelected: keys.reduce((acc, key) => {
acc[key] = false;
return acc;
}, {} as SelectionState),
}),
})),
getSelections: () => {
const state = get();
if (state.target !== 'cuesheet') return null;
return {
read: Object.entries(state.readSelected)
.filter(([_, selected]) => selected)
.map(([key]) => key),
write: Object.entries(state.writeSelected)
.filter(([_, selected]) => selected)
.map(([key]) => key),
};
},
}));
@@ -1,6 +1,4 @@
import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
import { makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => {
@@ -13,34 +11,3 @@ after newline,after comma
`);
});
});
describe('aggregateRundowns()', () => {
it('flattens an object of rundowns into a single array', () => {
const rundowns = {
first: {
id: '',
title: '',
revision: 0,
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['3', '4'],
flatOrder: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
},
} as Rundown,
} as ProjectRundowns;
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
});
});
-23
View File
@@ -1,5 +1,4 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/**
* Converts an array of arrays to a CSV file
@@ -7,25 +6,3 @@ import { OntimeEntry, ProjectRundowns } from 'ontime-types';
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays);
}
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
@@ -1,8 +0,0 @@
import { MaybeString } from 'ontime-types';
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
if (!value) return defaultValue;
const number = Number(value);
if (isNaN(number)) return defaultValue;
return number;
}
+32
View File
@@ -12,6 +12,8 @@ import { APP_SETTINGS } from '../api/constants';
import { useExpectedStartData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient';
import { ExtendedEntry } from './rundownMetadata';
/**
* Returns current time in milliseconds from midnight
* @returns {number}
@@ -154,3 +156,33 @@ export function useTimeUntilExpectedStart(
);
return expectedStart - clock;
}
export function getExpectedTimesFromExtendedEvent(
event: Pick<
ExtendedEntry<OntimeEvent>,
'timeStart' | 'dayOffset' | 'delay' | 'totalGap' | 'isLinkedToLoaded' | 'countToEnd' | 'duration'
> | null,
state: ReturnType<typeof useExpectedStartData>,
) {
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
const expectedStart = getExpectedStart(
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
{
totalGap: event.totalGap,
isLinkedToLoaded: event.isLinkedToLoaded,
...state,
},
);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd,
};
}
+5 -5
View File
@@ -33,8 +33,8 @@ export function validateProjectFile(file: File) {
}
// Limit file size of a project file to around 1MB
if (file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
if (file.size > 2_000_000) {
throw new Error('File size limit (2MB) exceeded');
}
}
@@ -56,8 +56,8 @@ export function validateLogo(file: File) {
throw new Error('File is empty');
}
// Limit file size of a project file to around 1MB
if (file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
// Limit file size of a project file to around 1.5MB
if (file.size > 1_500_000) {
throw new Error('File size limit (1.5MB) exceeded');
}
}
+3 -3
View File
@@ -17,11 +17,11 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
// resolve environment
export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isDocker = import.meta.env.IS_DOCKER; // this env is made available by the vite.config.js define function
export const isProduction = import.meta.env.PROD;
export const isDev = import.meta.env.DEV;
export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
// resolve entrypoint URLs
@@ -1,29 +0,0 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router';
import useUrlPresets from '../common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from '../common/utils/urlPresets';
const withPreset = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useUrlPresets();
const navigate = useNavigate();
const location = useLocation();
// navigate if is alias route
useEffect(() => {
if (!data) return;
const destination = getRouteFromPreset(location, data);
// navigate to this destination if its not null
if (destination) {
navigate(destination);
}
}, [data, navigate, location]);
return <Component {...(props as P)} />;
};
};
export default withPreset;
@@ -107,16 +107,16 @@ export default function AutomationsList(props: AutomationsListProps) {
</IconButton>
</Panel.InlineElements>
</tr>
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Card>
@@ -77,7 +77,7 @@ export default function OntimeActionForm({
New time
<Input
{...register(`outputs.${index}.time`, {
required: { value: true, message: 'Required field' }, //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='eg: 10m5s'
@@ -13,5 +13,5 @@
}
.current {
background-color: $blue-1100;
background-color: $blue-1100 !important; // fighting zebra styles
}
@@ -1,10 +1,13 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils';
@@ -55,6 +58,10 @@ export default function ManageRundowns() {
}
};
const handleDownloadXlsx = async (rundownId: string, title: string) => {
await downloadAsExcel(rundownId, title);
};
return (
<>
<Panel.Section>
@@ -93,19 +100,35 @@ export default function ManageRundowns() {
<td>
{title} {isLoaded && <Tag>Loaded</Tag>}
</td>
<Panel.InlineElements as='td'>
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
Load
</Button>
<Button
size='small'
variant='subtle-destructive'
onClick={() => openDelete(id)}
disabled={isLoaded}
<td>
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
items={[
{
type: 'item',
icon: IoDownloadOutline,
label: 'Load',
onClick: () => openLoad(id),
disabled: isLoaded,
},
{
type: 'item',
icon: IoDocumentOutline,
label: 'Download .xlsx',
onClick: () => handleDownloadXlsx(id, title),
},
{
type: 'item',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
disabled: isLoaded,
},
]}
>
Delete
</Button>
</Panel.InlineElements>
<IoEllipsisHorizontal />
</DropdownMenu>
</td>
</tr>
);
})}
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { CustomFields, Rundown } from 'ontime-types';
import Button from '../../../../../common/components/buttons/Button';
import useRundown from '../../../../../common/hooks-query/useRundown';
import * as Panel from '../../../panel-utils/PanelUtils';
import PreviewSpreadsheet from './preview/PreviewRundown';
@@ -17,7 +18,7 @@ interface ImportReviewProps {
export default function ImportReview(props: ImportReviewProps) {
const { rundown, customFields, onFinished, onCancel } = props;
const { data: currentRundown } = useRundown();
const [loading, setLoading] = useState(false);
const { importRundown } = useGoogleSheet();
const resetPreview = useSheetStore((state) => state.resetPreview);
@@ -29,9 +30,12 @@ export default function ImportReview(props: ImportReviewProps) {
const applyImport = async () => {
setLoading(true);
// we need to import on-top of the currently loaded rundown
// so the id needs to match
await importRundown(
{
[rundown.id]: rundown,
[currentRundown.id]: { ...rundown, id: currentRundown.id, title: currentRundown.title },
},
customFields,
);
@@ -59,7 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
timeWarning: namedImportMap['Time warning'],
timeDanger: namedImportMap['Time danger'],
custom,
entryId: namedImportMap.ID,
id: namedImportMap.ID,
};
}
@@ -4,7 +4,7 @@ import { MessageTag } from 'ontime-types';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket';
import { sendSocket } from '../../../../common/utils/socket';
import { isDockerImage } from '../../../../externals';
import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -18,7 +18,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
return (
<>
<Panel.Header>Network</Panel.Header>
{isDockerImage && (
{isDocker && (
<Panel.Section>
<OntimeCloudStats />
</Panel.Section>
@@ -10,7 +10,6 @@ import {
import {
deleteProject,
downloadCSV,
downloadProject,
duplicateProject,
loadProject,
@@ -187,10 +186,6 @@ function ActionMenu(props: ActionMenuProps) {
await downloadProject(filename);
};
const handleExportCSV = async () => {
await downloadCSV(filename);
};
return (
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
@@ -213,7 +208,6 @@ function ActionMenu(props: ActionMenuProps) {
{ type: 'item', icon: IoPencilOutline, label: 'Rename', onClick: handleRename },
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
{ type: 'item', icon: IoDocumentOutline, label: 'Export CSV Rundown', onClick: handleExportCSV },
{ type: 'divider' },
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: () => onDelete(filename), disabled: current },
]}
@@ -27,6 +27,7 @@ export default function ProjectData() {
reset,
formState: { isSubmitting, isValid, isDirty, errors },
setError,
clearErrors,
watch,
control,
setValue,
@@ -53,6 +54,7 @@ export default function ProjectData() {
const handleUploadProjectLogo = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
clearErrors('logo');
if (!file) {
return;
@@ -93,6 +95,7 @@ export default function ProjectData() {
const onSubmit = async (formData: ProjectData) => {
try {
clearErrors();
await updateProjectData(formData);
} catch (error) {
const message = maybeAxiosError(error);
@@ -31,7 +31,7 @@ const staticOptions = [
},
{
id: 'manage',
label: 'Project data',
label: 'Project settings',
secondary: [
{ id: 'manage__defaults', label: 'Rundown defaults' },
{ id: 'manage__custom', label: 'Custom fields' },
@@ -0,0 +1,84 @@
import { use, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export default function KeepAwake() {
const { keepAwake } = useKeepAwakeOptions();
const [wakeLockSentinel, setWakeLockSentinel] = useState<WakeLockSentinel | null>(null);
const removeLock = () => {
if (wakeLockSentinel) wakeLockSentinel.release().finally(() => setWakeLockSentinel(null));
};
const acquireLock = () => {
if (!wakeLockSentinel || wakeLockSentinel.released) {
setWakeLockSentinel(null);
navigator.wakeLock
.request('screen')
.then((sentinel) => {
setWakeLockSentinel(sentinel);
})
.catch(console.error);
}
};
useEffect(() => {
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinel !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
return <></>;
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
/**
* Hook exposes the keep awake options
*/
export function useKeepAwakeOptions() {
const [searchParams, setSearchParams] = useSearchParams();
const maybePreset = use(PresetContext);
const keepAwake = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake]);
return { keepAwake, toggleKeepAwake };
}
+28 -30
View File
@@ -6,13 +6,10 @@ import ViewParamsEditor from '../../common/components/view-params-editor/ViewPar
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal';
import FollowButton from './follow-button/FollowButton';
@@ -21,22 +18,32 @@ import OperatorGroup from './operator-group/OperatorGroup';
import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions, useOperatorOptions } from './operator.options';
import type { EditEvent } from './operator.types';
import { getEventData, makeOperatorMetadata } from './operator.utils';
import { getEventData } from './operator.utils';
import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss';
const selectedOffset = 50;
export default function Operator() {
const { data, status } = useRundown();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
export default function OperatorLoader() {
const { data, status } = useOperatorData();
const timeoutId = useRef<NodeJS.Timeout | null>(null);
useWindowTitle('Operator');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
}
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const { selectedEventId } = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
@@ -52,7 +59,7 @@ export default function Operator() {
followTrigger: selectedEventId,
});
useWindowTitle('Operator');
const timeoutId = useRef<NodeJS.Timeout | null>(null);
// reset scroll if nothing is selected
useEffect(() => {
@@ -101,19 +108,11 @@ export default function Operator() {
setEditEvent({ ...event });
}, []);
const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
if (missingData || isLoading) {
return <EmptyPage text='Loading...' />;
}
const canEdit = shouldEdit && subscribe.length;
const { process } = makeOperatorMetadata(selectedEventId);
return (
<div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,11 +126,10 @@ export default function Operator() {
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.order.map((entryId) => {
const entry = data.entries[entryId];
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(entry);
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
@@ -158,9 +156,9 @@ export default function Operator() {
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isSelected}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
@@ -174,12 +172,12 @@ export default function Operator() {
<Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} />
{entry.entries.map((nestedEntryId) => {
const nestedEntry = data.entries[nestedEntryId];
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
@@ -207,9 +205,9 @@ export default function Operator() {
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isSelected}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
@@ -1,13 +1,10 @@
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { getIsNavigationLocked } from '../../externals';
import Operator from './Operator';
export default function OperatorExport() {
return (
<ProtectRoute permission='operator'>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator />
</ProtectRoute>
);
@@ -1,83 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { makeOperatorMetadata } from '../operator.utils';
describe('makeOperatorMetadata()', () => {
it('should track past, selected states, gaps and linking', () => {
const event1 = { id: 'event1', gap: 5, linkStart: false } as OntimeEvent;
const event2 = { id: 'event2', gap: 10, linkStart: true } as OntimeEvent;
const event3 = { id: 'event3', gap: 15, linkStart: true } as OntimeEvent;
const { process } = makeOperatorMetadata('event2');
expect(process(event1)).toEqual({
isPast: true,
isSelected: false,
totalGap: 5,
isLinkedToLoaded: false,
});
expect(process(event2)).toEqual({
isPast: false,
isSelected: true,
totalGap: 15,
isLinkedToLoaded: false,
});
expect(process(event3)).toEqual({
isPast: false,
isSelected: false,
totalGap: 30,
isLinkedToLoaded: true,
});
});
it('should handle null selectedId', () => {
const event1 = { id: 'event1', gap: 5, linkStart: true } as OntimeEvent;
const event2 = { id: 'event2', gap: 10, linkStart: false } as OntimeEvent;
const event3 = { id: 'event3', gap: 15, linkStart: true } as OntimeEvent;
const { process } = makeOperatorMetadata(null);
expect(process(event1)).toEqual({
isPast: false,
isSelected: false,
totalGap: 5,
isLinkedToLoaded: true,
});
expect(process(event2)).toEqual({
isPast: false,
isSelected: false,
totalGap: 15,
isLinkedToLoaded: false,
});
expect(process(event3)).toEqual({
isPast: false,
isSelected: false,
totalGap: 30,
isLinkedToLoaded: true,
});
});
it('should break linking chain on countToEnd events', () => {
const event1 = { id: 'event1', gap: 5, linkStart: true, countToEnd: false } as OntimeEvent;
const event2 = { id: 'event2', gap: 10, linkStart: true, countToEnd: true } as OntimeEvent;
const event3 = { id: 'event3', gap: 15, linkStart: true, countToEnd: false } as OntimeEvent;
const { process } = makeOperatorMetadata(null);
expect(process(event1)).toEqual({
isPast: false,
isSelected: false,
totalGap: 5,
isLinkedToLoaded: true,
});
expect(process(event2)).toEqual({
isPast: false,
isSelected: false,
totalGap: 15,
isLinkedToLoaded: true,
});
expect(process(event3)).toEqual({
isPast: false,
isSelected: false,
totalGap: 30,
isLinkedToLoaded: false,
});
});
});
@@ -71,7 +71,7 @@ function OperatorEvent({
]);
return (
<div className={operatorClasses} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
<div className={operatorClasses} data-testid={cue} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
<div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span>
</div>
@@ -167,5 +167,5 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return <span className={style.timeUntil}>{timeUntilString}</span>;
return <span className={style.timeUntil} data-testid='time-until'>{timeUntilString}</span>;
}
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
@@ -9,6 +9,7 @@ import {
makeCustomFieldSelectOptions,
makeOptionsFromCustomFields,
} from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../viewers/common/viewUtils';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
@@ -93,15 +94,26 @@ type OperatorOptions = {
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): OperatorOptions {
// Helper to get value from either source, prioritizing defaultValues
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
// Helper to get array values from either source
const getArrayValues = (key: string): EntryId[] => {
if (defaultValues?.has(key)) {
return defaultValues.getAll(key) as EntryId[];
}
return searchParams.getAll(key) as EntryId[];
};
// we manually make an object that matches the key above
return {
mainSource: searchParams.get('main') as keyof OntimeEvent | null,
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
subscribe: searchParams.getAll('subscribe'),
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')),
hidePast: isStringBoolean(searchParams.get('hidePast')),
showStart: isStringBoolean(searchParams.get('showStart')),
mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
subscribe: getArrayValues('subscribe'),
shouldEdit: isStringBoolean(getValue('shouldEdit')),
hidePast: isStringBoolean(getValue('hidePast')),
showStart: isStringBoolean(getValue('showStart')),
};
}
@@ -110,6 +122,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
*/
export function useOperatorOptions(): OperatorOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
const maybePreset = use(PresetContext);
const options = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
return options;
}
@@ -1,74 +1,33 @@
import { CustomFields, EntryId, MaybeString, OntimeEvent } from 'ontime-types';
import { getPropertyValue } from '../viewers/common/viewUtils';
import type { Subscribed } from './operator.types';
type OperatorMetadata = {
isLinkedToLoaded: boolean;
isPast: boolean;
isSelected: boolean;
totalGap: number;
};
export function makeOperatorMetadata(selectedId: EntryId | null) {
const hasSelection = Boolean(selectedId);
let hasSeenSelected = false;
let totalGap = 0;
/** if the event can link all the way back to the currently playing event */
let isLinkedToLoaded = false;
let previousEvent: OntimeEvent | null = null;
function process(event: OntimeEvent): Readonly<OperatorMetadata> {
const isSelected = event.id === selectedId;
if (isSelected) {
hasSeenSelected = true;
}
// is past if we havent yet seen the selected event
const isPast = hasSelection && !hasSeenSelected;
totalGap += event.gap;
if (!isPast && !isSelected) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
isLinkedToLoaded = event.linkStart && !previousEvent?.countToEnd;
}
previousEvent = event;
return { isPast, isSelected, totalGap, isLinkedToLoaded };
}
return { process };
}
export function getEventData(
event: OntimeEvent,
main: MaybeString,
secondary: MaybeString,
subscriptions: string[],
customFields: CustomFields,
) {
const mainField = main ? getPropertyValue(event, main) ?? '' : event.title;
const secondaryField = getPropertyValue(event, secondary) ?? '';
// remove subscriptions that are not in customFields
const sanitisedSubscriptions = subscriptions.filter((field) => Object.hasOwn(customFields, field));
const subscribedData = sanitisedSubscriptions.reduce<Subscribed>((acc, id) => {
const field = customFields[id];
if (field) {
acc.push({
id,
label: field.label,
colour: field.colour,
value: event.custom[id],
});
}
return acc;
}, []);
return { mainField, secondaryField, subscribedData };
}
import { CustomFields, MaybeString, OntimeEvent } from 'ontime-types';
import { getPropertyValue } from '../viewers/common/viewUtils';
import type { Subscribed } from './operator.types';
export function getEventData(
event: OntimeEvent,
main: MaybeString,
secondary: MaybeString,
subscriptions: string[],
customFields: CustomFields,
) {
const mainField = main ? getPropertyValue(event, main) ?? '' : event.title;
const secondaryField = getPropertyValue(event, secondary) ?? '';
// remove subscriptions that are not in customFields
const sanitisedSubscriptions = subscriptions.filter((field) => Object.hasOwn(customFields, field));
const subscribedData = sanitisedSubscriptions.reduce<Subscribed>((acc, id) => {
const field = customFields[id];
if (field) {
acc.push({
id,
label: field.label,
colour: field.colour,
value: event.custom[id],
});
}
return acc;
}, []);
return { mainField, secondaryField, subscribedData };
}
@@ -0,0 +1,30 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { aggregateQueryStatus, ViewData } from '../../views/utils/viewLoader.utils';
export interface OperatorData {
rundown: Rundown;
rundownMetadata: RundownMetadataObject;
customFields: CustomFields;
settings: Settings;
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
return {
data: {
rundown,
rundownMetadata,
customFields,
settings,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
};
}
@@ -20,7 +20,7 @@ import {
useNextFlag,
useOffsetOverview,
useProgressOverview,
useRundownOverview,
useStartTimesOverview,
useTimer,
} from '../../../common/hooks/useSocket';
import { useEntry } from '../../../common/hooks-query/useRundown';
@@ -34,7 +34,7 @@ import { OverUnder, TimeColumn } from './TimeLayout';
import style from './TimeElements.module.scss';
export function StartTimes() {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRundownOverview();
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useStartTimesOverview();
const plannedStartText = plannedStart === null ? timerPlaceholder : formatTime(plannedStart);
@@ -50,7 +50,7 @@ export function StartTimes() {
<Tooltip text='Planned start time' render={<TbCalendarPin className={style.icon} />} />
<span className={cx([style.time, plannedStart === null && style.muted])}>{plannedStartText}</span>
</div>
<div className={style.labelledElement}>
<div className={style.labelledElement} data-testid='actual-start-time'>
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} />
<span className={cx([style.time, actualStart === null && style.muted])}>{formattedTime(actualStart)}</span>
</div>
@@ -37,7 +37,6 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -205,7 +205,6 @@ export default function RundownEvent({
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -114,7 +114,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'grab',
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'inherit',
};
return (
@@ -70,7 +70,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -225,6 +225,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
<Switch
size='large'
name='lockNav'
data-testid='lockNav'
checked={watch('lockNav')}
onCheckedChange={(checked) => setValue('lockNav', checked, { shouldDirty: true })}
disabled={watch('lockConfig')}
@@ -239,6 +240,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
<Switch
size='large'
name='lockConfig'
data-testid='lockConfig'
checked={watch('lockConfig')}
onCheckedChange={(checked) => {
if (checked) {
@@ -254,6 +256,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
<Switch
size='large'
name='authenticate'
data-testid='authenticate'
checked={watch('authenticate')}
onCheckedChange={(checked) => setValue('authenticate', checked, { shouldDirty: true })}
/>
-1
View File
@@ -63,7 +63,6 @@ body,
html {
font-size: 15px;
font-family: $ontime-font-family;
background-color: var(--background-color-override, $ui-black);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
+1 -2
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router';
import { Routes } from 'react-router';
import { createRoutesFromChildren, matchRoutes, Routes, useLocation, useNavigationType } from 'react-router';
import * as Sentry from '@sentry/react';
import { ONTIME_VERSION } from './ONTIME_VERSION';
+1 -1
View File
@@ -42,4 +42,4 @@ $timer-bold-font-family: 'Arial Black', sans-serif; // --font-family-bold-overri
$external-color: rgba(white, 85%); // --external-color-override
// properties related to the studio clock
$studio-breakpoint: 1200px;
$studio-breakpoint: 1300px;
@@ -17,7 +17,7 @@ export const getBackstageOptions = (
customFields: CustomFields,
projectData: ProjectData,
): ViewOption[] => {
const customFieldOptions = makeOptionsFromCustomFields(customFields, []);
const customFieldOptions = makeOptionsFromCustomFields(customFields, [{ value: 'none', label: 'None' }]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
@@ -2,10 +2,16 @@
.notFound {
display: grid;
place-items: center;
height: 100vh;
padding-top: 20vh;
background-color: var(--background-color-override, $viewer-background-color);
margin-top: 10vh;
}
.content {
display: flex;
flex-direction: column;
color: $ui-white;
justify-content: center;
align-items: center;
text-align: center;
}
@@ -5,15 +5,17 @@ import style from './NotFound.module.scss';
export default function NotFound() {
return (
<div className={style.notFound}>
<EmptyImage />
<h1>Not found</h1>
<div>
The page you are after was not found.
<br />
It may have moved or your URL may be incorrect.
<br />
Double check the URL and try again.
</div>
<section className={style.content}>
<EmptyImage />
<h1>Not found</h1>
<div>
The page you are after was not found.
<br />
It may have moved or your URL may be incorrect.
<br />
Double check the URL and try again.
</div>
</section>
</div>
);
}
@@ -1,6 +1,5 @@
import { cx } from '../../../common/utils/styleUtils';
import { getScheduledTimes } from './schedule.utils';
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
@@ -20,17 +19,21 @@ export default function Schedule({ className }: ScheduleProps) {
return (
<ul className={cx(['schedule', className])} ref={containerRef}>
{events.map((event) => {
const { timeStart, timeEnd, delay } = getScheduledTimes(event);
return (
<ScheduleItem
key={event.id}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
timeStart={event.timeStart}
dayOffset={event.dayOffset}
delay={event.delay}
totalGap={event.totalGap}
isLinkedToLoaded={event.isLinkedToLoaded}
countToEnd={event.countToEnd}
duration={event.duration}
colour={event.colour}
skip={event.skip}
delay={delay}
title={event.title}
timeEnd={event.timeEnd}
cue={event.cue}
/>
);
})}
@@ -2,11 +2,12 @@ import { createContext, PropsWithChildren, RefObject, use, useEffect, useLayoutE
import { EntryId, isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useScheduleOptions } from './schedule.options';
interface ScheduleContextState {
events: OntimeEvent[];
events: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null;
numPages: number;
visiblePage: number;
@@ -21,7 +22,7 @@ interface ScheduleProviderProps {
export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren<ScheduleProviderProps>) => {
const { cycleInterval, stopCycle, filter } = useScheduleOptions();
const { data: events } = usePartialRundown((entry: OntimeEntry) => {
const { data: events } = usePartialRundown((entry: ExtendedEntry<OntimeEntry>) => {
if (filter) {
// custom keys are prepended with custom-
const customKey = filter.startsWith('custom-') ? filter.slice('custom-'.length) : filter;
@@ -133,7 +134,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
return (
<ScheduleContext
value={{
events: viewEvents as OntimeEvent[],
events: viewEvents as ExtendedEntry<OntimeEvent>[],
selectedEventId,
numPages,
visiblePage,
@@ -1,7 +1,10 @@
import { useRuntimeOffset } from '../../../common/hooks/useSocket';
import { OntimeEvent } from 'ontime-types';
import { useExpectedStartData } from '../../../common/hooks/useSocket';
import { getOffsetState } from '../../../common/utils/offset';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import { formatTime, getExpectedTimesFromExtendedEvent } from '../../../common/utils/time';
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { useScheduleOptions } from './schedule.options';
@@ -13,110 +16,150 @@ const formatOptions = {
format24: 'HH:mm',
};
interface ScheduleItemProps {
timeStart: number;
timeEnd: number;
title: string;
colour?: string;
skip?: boolean;
delay: number;
}
type ScheduleItemProps = Pick<
ExtendedEntry<OntimeEvent>,
| 'timeStart'
| 'dayOffset'
| 'delay'
| 'totalGap'
| 'isLinkedToLoaded'
| 'countToEnd'
| 'duration'
| 'colour'
| 'skip'
| 'title'
| 'timeEnd'
| 'cue'
>;
export default function ScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
export default function ScheduleItem({
timeStart,
dayOffset,
delay,
totalGap,
isLinkedToLoaded,
countToEnd,
colour,
duration,
skip,
title,
timeEnd,
cue,
}: ScheduleItemProps) {
const { showExpected } = useScheduleOptions();
if (showExpected) {
return (
<ExpectedScheduleItem
timeStart={timeStart}
timeEnd={timeEnd}
title={title}
colour={colour}
skip={skip}
delay={delay}
/>
);
}
if (delay > 0) {
return (
<DelayedScheduleItem
timeStart={timeStart}
timeEnd={timeEnd}
title={title}
colour={colour}
skip={skip}
delay={delay}
/>
);
}
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<li className={cx(['entry', skip && 'entry--skip'])} data-testid={cue}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
<SuperscriptTime time={end} />
{showExpected ? (
<ExpectedScheduleItem
timeStart={timeStart}
dayOffset={dayOffset}
delay={delay}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
countToEnd={countToEnd}
duration={duration}
colour={colour}
/>
) : delay > 0 ? (
<DelayedScheduleItem timeStart={timeStart} delay={delay} colour={colour} timeEnd={timeEnd} />
) : (
<PlannedScheduleItem timeStart={timeStart} timeEnd={timeEnd} colour={colour} />
)}
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
function DelayedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
function PlannedScheduleItem({
timeStart,
timeEnd,
colour,
}: Pick<ScheduleItemProps, 'timeStart' | 'timeEnd' | 'colour'>) {
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
return (
<>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
<SuperscriptTime time={end} />
</>
);
}
function DelayedScheduleItem({
timeStart,
timeEnd,
colour,
delay,
}: Pick<ScheduleItemProps, 'timeStart' | 'timeEnd' | 'colour' | 'delay'>) {
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const delayedStart = formatTime(timeStart + delay, formatOptions);
const delayedEnd = formatTime(timeEnd + delay, formatOptions);
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-times--delayed'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
<SuperscriptTime time={end} />
</span>
<span className='entry-times--delay'>
<SuperscriptTime time={delayedStart} />
<SuperscriptTime time={delayedEnd} />
</span>
</div>
<div className='entry-title'>{title}</div>
</li>
<>
<span className='entry-times--delayed'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<SuperscriptTime time={start} />
<SuperscriptTime time={end} />
</span>
<span className='entry-times--delay'>
<SuperscriptTime time={delayedStart} />
<SuperscriptTime time={delayedEnd} />
</span>
</>
);
}
function ExpectedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
function ExpectedScheduleItem({
timeStart,
dayOffset,
delay,
totalGap,
isLinkedToLoaded,
countToEnd,
colour,
duration,
}: Omit<ScheduleItemProps, 'timeEnd' | 'cue' | 'skip' | 'title'>) {
const expectedStartData = useExpectedStartData();
const { expectedStart, expectedEnd, plannedEnd } = getExpectedTimesFromExtendedEvent(
{
timeStart,
dayOffset,
delay,
totalGap,
isLinkedToLoaded,
countToEnd,
duration,
},
expectedStartData,
);
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<ExpectedTime time={timeStart} delay={delay} />
<ExpectedTime time={timeEnd} delay={delay} />
</div>
<div className='entry-title'>{title}</div>
</li>
<>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<ExpectedTime expectedTime={expectedStart} plannedTime={timeStart} />
<ExpectedTime expectedTime={expectedEnd} plannedTime={plannedEnd} />
</>
);
}
interface ExpectedTimeProps {
time: number;
delay: number;
expectedTime: number;
plannedTime: number;
}
function ExpectedTime({ time, delay }: ExpectedTimeProps) {
const { offset } = useRuntimeOffset();
const expectedOffset = offset - delay;
const expectedTime = formatTime(time + offset, formatOptions);
const expectedState = getOffsetState(expectedOffset);
return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />;
function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) {
const timeDisplay = formatTime(expectedTime);
const expectedState = getOffsetState(expectedTime - plannedTime);
return <SuperscriptTime className={`entry-times--${expectedState}`} time={timeDisplay} />;
}
@@ -16,7 +16,7 @@ export const getScheduleOptions = (customFieldOptions: SelectOption[]): ViewOpti
description: 'Hide events without data in the selected custom field',
type: 'option',
values: customFieldOptions,
defaultValue: 'None',
defaultValue: 'none',
},
{
id: 'stopCycle',
@@ -1,12 +0,0 @@
import { OntimeEvent } from 'ontime-types';
/**
* Gather rules for how to present scheduled times
*/
export function getScheduledTimes(event: OntimeEvent) {
return {
timeStart: event.timeStart,
timeEnd: event.timeEnd,
delay: event.skip ? 0 : event.delay,
};
}
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import { EntryId, OntimeEvent } from 'ontime-types';
import { EntryId, PlayableEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import { cx } from '../../common/utils/styleUtils';
@@ -12,7 +12,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss';
interface CountdownSelectProps {
events: OntimeEvent[];
events: PlayableEvent[];
subscriptions: EntryId[];
disableEdit: () => void;
}
@@ -52,7 +52,7 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
return (
<div className='list-container'>
{events.map((event: OntimeEvent, index: number) => {
{events.map((event, index) => {
const title = event.title || '{no title}';
const isSelected = selectedIds.has(event.id);
@@ -109,6 +109,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
key={event.id}
ref={isLive ? selectedRef : undefined}
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
data-testid={event.cue}
>
<div className='sub__binder' style={{ '--user-color': event.colour }} />
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
@@ -1,6 +1,4 @@
import { ProjectData } from 'ontime-types';
import { makeTable, parseField } from '../cuesheet.utils';
import { parseField } from '../cuesheet.utils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
@@ -32,69 +30,3 @@ describe('parseField()', () => {
});
});
});
describe('makeTable()', () => {
it('returns array of arrays with given fields', () => {
const headerData = {
title: 'test title',
description: 'test description',
logo: 'test logo',
};
const tableData = [
{
title: 'test title 1',
timeStart: 0,
timeEnd: 0,
skip: true,
lighting: { value: 'test lighting' },
sound: { value: 'test sound' },
},
];
const customFields = {
lighting: { label: 'test' },
};
// @ts-expect-error -- testing user data with missing fields
const table = makeTable(headerData as ProjectData, tableData, customFields);
expect(table).not.toContain('test logo');
expect(table).toMatchInlineSnapshot(`
[
[
"Ontime · Rundown export",
],
[
"Project title: test title",
],
[
"Project description: test description",
],
[
"Time Start",
"Time End",
"Duration",
"ID",
"Colour",
"Cue",
"Title",
"Note",
"Skip?",
"lighting",
"Type",
],
[
"00:00:00",
"00:00:00",
"",
"",
"",
"",
"test title 1",
"",
"x",
"",
"",
],
]
`);
});
});
@@ -24,7 +24,6 @@ export function SortableCell({ columnId, colSpan, injectedStyles, children, drag
const dragStyle = {
...injectedStyles,
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -216,7 +216,7 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
@@ -1,51 +0,0 @@
import { useVisibleRowsStore } from './visibleRowsStore';
let observer: IntersectionObserver | null = null;
function getObserver(): IntersectionObserver {
if (!observer) {
const options: IntersectionObserverInit = {
root: null,
rootMargin: '400px 0px', // prevent unmounting rows too early
threshold: 0.25,
};
const handleOnIntersect: IntersectionObserverCallback = (entries) => {
const visibleRows = useVisibleRowsStore.getState();
entries.forEach((entry) => {
const targetId = entry.target.id;
if (entry.isIntersecting) {
visibleRows.addVisibleRow(targetId);
} else {
visibleRows.removeVisibleRow(targetId);
}
});
};
observer = new IntersectionObserver(handleOnIntersect, options);
}
return observer;
}
/**
* register a row element in the observer
*/
export function observeRow(element: HTMLElement) {
getObserver().observe(element);
}
/**
* unregister a row element in the observer
*/
export function unobserveRow(element: HTMLElement) {
getObserver().unobserve(element);
}
/**
* cleanup observer, should be called when the table component unmounts
*/
export function cleanup() {
observer?.disconnect();
observer = null;
}
@@ -1,18 +0,0 @@
import { create } from 'zustand';
interface VisibleRowsStore {
visibleRows: Set<string>;
addVisibleRow: (id: string) => void;
removeVisibleRow: (id: string) => void;
}
export const useVisibleRowsStore = create<VisibleRowsStore>((set) => ({
visibleRows: new Set(),
addVisibleRow: (id) => set((state) => ({ visibleRows: new Set(state.visibleRows).add(id) })),
removeVisibleRow: (id) =>
set((state) => {
const newSet = new Set(state.visibleRows);
newSet.delete(id);
return { visibleRows: newSet };
}),
}));
@@ -1,12 +1,4 @@
import {
CustomFields,
isOntimeDelay,
isOntimeEvent,
MaybeNumber,
OntimeEntry,
OntimeEntryCommonKeys,
ProjectData,
} from 'ontime-types';
import { CustomFields, MaybeNumber, OntimeEntryCommonKeys } from 'ontime-types';
import { millisToString } from 'ontime-utils';
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
@@ -29,72 +21,3 @@ export const parseField = (field: CsvHeaderKey, data: unknown): string => {
return String(data ?? '');
};
/**
* @description Creates an array of arrays usable by xlsx for export
*/
export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], customFields: CustomFields): string[][] => {
// create metadata header row
const data = [['Ontime · Rundown export']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]);
if (headerData.description) data.push([`Project description: ${headerData.description}`]);
const customFieldKeys = Object.keys(customFields).map((key) => `custom-${key}`);
const customFieldLabels = Object.keys(customFields);
// we chose not to expose internals of the application
const fieldOrder: CsvHeaderKey[] = [
'timeStart',
'timeEnd',
'duration',
'id',
'colour',
'cue',
'title',
'note',
'skip',
...customFieldKeys,
'type',
];
const fieldTitles = [
'Time Start',
'Time End',
'Duration',
'ID',
'Colour',
'Cue',
'Title',
'Note',
'Skip?',
...customFieldLabels,
'Type',
];
// add header row to data
data.push(fieldTitles);
rundown.forEach((entry) => {
if (isOntimeDelay(entry)) return;
const row: string[] = [];
fieldOrder.forEach((field) => {
if (isOntimeEvent(entry)) {
// for custom fields, we need to extract the value from the custom object
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
const value = entry.custom[fieldLabel];
row.push(parseField(fieldLabel, value));
} else {
// @ts-expect-error -- it is ok, we will just not have the data for other fields
row.push(parseField(field, entry[field]));
}
return;
}
// @ts-expect-error -- it is ok, we will just not have the data for other fields
row.push(parseField(field, entry[field]));
});
data.push(row);
});
return data;
};
+3 -3
View File
@@ -1,6 +1,6 @@
import { Playback } from 'ontime-types';
import { useIsMobileDevice } from '../../common/hooks/useIsMobileDevice';
import { useIsSmallScreen } from '../../common/hooks/useIsSmallScreen';
import { useStudioClockSocket } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils';
import { formatTime } from '../../common/utils/time';
@@ -18,12 +18,12 @@ interface StudioClockProps {
}
export default function StudioClock({ hideCards }: StudioClockProps) {
const isMobile = useIsMobileDevice();
const isSmallScreen = useIsSmallScreen();
const { clock, playback } = useStudioClockSocket();
const onAir = playback !== Playback.Stop;
// if we are on mobile and have to show the cards
if (isMobile && !hideCards) {
if (isSmallScreen && !hideCards) {
return <StudioClockMobile clock={clock} onAir={onAir} />;
}
@@ -4,7 +4,7 @@
display: flex;
flex-direction: column;
gap: $view-element-gap;
margin-top: 5%;
margin-block: auto;
font-size: $base-font-size;
.card {
@@ -17,17 +17,17 @@ interface StudioTimersProps {
export default function StudioTimers({ viewSettings }: StudioTimersProps) {
const { getLocalizedString } = useTranslation();
const { eventNow, eventNext, message, time, offset, rundown } = useStudioTimersSocket();
const { eventNow, eventNext, message, time, offset, rundown, expectedRundownEnd } = useStudioTimersSocket();
const schedule = getFormattedScheduleTimes({
offset: offset.absolute,
offset: offset,
actualStart: rundown.actualStart,
expectedEnd: offset.expectedRundownEnd,
expectedEnd: expectedRundownEnd,
});
const event = getFormattedEventData(eventNow, time);
const eventNextTitle = eventNext?.title || '-';
const formattedTimerMessage = (message.timer.visible && message.timer.text) || '-';
const formattedSecondaryMessage = message.secondary || '-';
const formattedSecondaryMessage = message.timer.secondarySource === 'secondary' ? message.secondary || '-' : '-';
// gather presentation styles
const timerColour = getTimerColour(
@@ -37,7 +37,7 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
time.phase === TimerPhase.Danger,
);
const offsetState = getOffsetState(offset.absolute);
const offsetState = getOffsetState(offset);
return (
<div className='studio__timers'>
+7 -2
View File
@@ -4,6 +4,7 @@ import { isOntimeEvent, isPlayableEvent, OntimeEntry, PlayableEvent } from 'onti
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import useHorizontalFollowComponent from '../../common/hooks/useHorizontalFollowComponent';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
@@ -15,7 +16,7 @@ import style from './Timeline.module.scss';
interface TimelineProps {
firstStart: number;
rundown: OntimeEntry[];
rundown: ExtendedEntry<OntimeEntry>[];
selectedEventId: string | null;
totalDuration: number;
}
@@ -45,7 +46,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
const { positions, totalWidth } = useMemo(() => {
const playableEvents = rundown
.filter((event): event is PlayableEvent => isOntimeEvent(event) && isPlayableEvent(event))
.filter((event): event is ExtendedEntry<PlayableEvent> => isOntimeEvent(event) && isPlayableEvent(event))
.map((event) => ({
start: event.timeStart + (event.dayOffset ?? 0) * dayInMs + (event.delay ?? 0),
duration: event.duration,
@@ -96,7 +97,11 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
left={position.left}
status={statusMap[event.id]}
start={event.timeStart + (event.dayOffset ?? 0) * dayInMs}
totalGap={event.totalGap}
isLinkedToLoaded={event.isLinkedToLoaded}
dayOffset={event.dayOffset}
title={event.title}
cue={event.cue}
width={position.width}
/>
);
@@ -1,12 +1,12 @@
import { RefObject } from 'react';
import { useTimelineStatus, useTimer } from '../../common/hooks/useSocket';
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress';
import { alpha, cx } from '../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../common/utils/time';
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import { getStatusLabel, getTimeToStart } from './timeline.utils';
import { getStatusLabel } from './timeline.utils';
import style from './Timeline.module.scss';
@@ -20,8 +20,12 @@ interface TimelineEntryProps {
left: number;
status: ProgressStatus;
start: number;
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
title: string;
width: number;
cue: string;
ref?: RefObject<HTMLDivElement | null>;
}
@@ -38,8 +42,12 @@ export function TimelineEntry({
left,
status,
start,
dayOffset,
totalGap,
isLinkedToLoaded,
title,
width,
cue,
ref,
}: TimelineEntryProps) {
const formattedStartTime = formatTime(start, formatOptions);
@@ -61,6 +69,7 @@ export function TimelineEntry({
left: `${left}px`,
width: `${width}px`,
}}
data-testid={cue}
>
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
<div
@@ -73,11 +82,29 @@ export function TimelineEntry({
<div className={style.maybeInline}>
<div className={cx([hasDelay && style.cross])}>{formattedStartTime}</div>
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
{smallArea && <TimelineEntryStatus delay={delay} start={start} status={status} />}
{smallArea && (
<TimelineEntryStatus
delay={delay}
start={start}
dayOffset={dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
status={status}
/>
)}
</div>
{showTitle && (
<>
{!smallArea && <TimelineEntryStatus delay={delay} start={start} status={status} />}
{!smallArea && (
<TimelineEntryStatus
delay={delay}
start={start}
dayOffset={dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
status={status}
/>
)}
<div>{title}</div>
</>
)}
@@ -92,16 +119,31 @@ export function TimelineEntry({
interface TimelineEntryStatusProps {
delay: number;
start: number;
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
status: ProgressStatus;
}
// extract component to isolate re-renders provoked by the clock changes
function TimelineEntryStatus({ delay, start, status }: TimelineEntryStatusProps) {
const { clock, offset } = useTimelineStatus();
function TimelineEntryStatus({
delay,
start,
dayOffset,
totalGap,
isLinkedToLoaded,
status,
}: TimelineEntryStatusProps) {
const state = useExpectedStartData();
const { getLocalizedString } = useTranslation();
// start times need to be normalised in a rundown that crosses midnight
let statusText = getStatusLabel(getTimeToStart(clock, start, delay, offset), status);
const { timeToStart } = getExpectedTimesFromExtendedEvent(
{ timeStart: start, delay, dayOffset, totalGap, isLinkedToLoaded, countToEnd: false, duration: 0 },
state,
);
let statusText = getStatusLabel(timeToStart, status);
if (statusText === 'live') {
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
@@ -111,7 +111,7 @@
color: $red-500;
}
.section-content--next {
.section-content--followedBy .section-content--next {
color: $green-500;
}
@@ -1,21 +1,22 @@
import { OntimeEvent } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { useTimelineSocket } from '../../common/hooks/useSocket';
import { formatDuration } from '../../common/utils/time';
import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatDuration, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import TimelineSection from './timeline-section/TimelineSection';
import { getTimeToStart } from './timeline.utils';
interface TimelineSectionsProps {
now: OntimeEvent | null;
next: OntimeEvent | null;
followedBy: OntimeEvent | null;
now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null;
followedBy: ExtendedEntry<OntimeEvent> | null;
}
export default function TimelineSections({ now, next, followedBy }: TimelineSectionsProps) {
const { getLocalizedString } = useTranslation();
const { clock, offset } = useTimelineSocket();
const state = useExpectedStartData();
// gather card data
const titleNow = now?.title ?? '-';
@@ -26,20 +27,20 @@ export default function TimelineSections({ now, next, followedBy }: TimelineSect
let followedByStatus: string | undefined;
if (next !== null) {
const timeToStart = getTimeToStart(clock, next.timeStart, next?.delay ?? 0, offset);
if (timeToStart < 0) {
const { timeToStart } = getExpectedTimesFromExtendedEvent(next, state);
if (timeToStart <= 0) {
nextStatus = dueText;
} else {
nextStatus = `T - ${formatDuration(timeToStart)}`;
nextStatus = formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 2);
}
}
if (followedBy !== null) {
const timeToStart = getTimeToStart(clock, followedBy.timeStart, followedBy?.delay ?? 0, offset);
if (timeToStart < 0) {
const { timeToStart } = getExpectedTimesFromExtendedEvent(followedBy, state);
if (timeToStart <= 0) {
followedByStatus = dueText;
} else {
followedByStatus = `T - ${formatDuration(timeToStart)}`;
followedByStatus = formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 2);
}
}
@@ -56,7 +57,7 @@ export default function TimelineSections({ now, next, followedBy }: TimelineSect
title={getLocalizedString('timeline.followedby')}
status={followedByStatus}
content={followedByText}
category='next'
category='followedBy'
/>
</div>
);
@@ -4,7 +4,7 @@ import { MaybeString } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
interface SectionProps {
category: 'now' | 'next';
category: 'now' | 'next' | 'followedBy';
content: MaybeString;
title: string;
status?: string;
@@ -16,7 +16,7 @@ function TimelineSection({ category, content, title, status }: SectionProps) {
const sectionClasses = cx(['section', category === 'now' && 'section--now']);
const contentClasses = cx(['section-content', content ? `section-content--${category}` : 'section-content--subdue']);
return (
<div className={sectionClasses}>
<div className={sectionClasses} data-testid={category}>
<div className='section-title'>
<span className='section-title__label'>{title}</span>
{status && <span className='section-title__status'>{status}</span>}
@@ -8,8 +8,10 @@ import {
getTimeFrom,
isNewLatest,
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
} from 'ontime-utils';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatDuration } from '../../common/utils/time';
import { useTimelineOptions } from './timeline.options';
@@ -76,20 +78,23 @@ export function getStatusLabel(timeToStart: number, status: ProgressStatus): str
return status;
}
if (timeToStart < 0) {
if (timeToStart <= 0) {
return 'pending';
}
return formatDuration(timeToStart);
return formatDuration(timeToStart, timeToStart > MILLIS_PER_MINUTE * 2);
}
interface ScopedRundownData {
scopedRundown: PlayableEvent[];
scopedRundown: ExtendedEntry<PlayableEvent>[];
firstStart: number;
totalDuration: number;
}
export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeString): ScopedRundownData {
export function useScopedRundown(
rundown: ExtendedEntry<OntimeEntry>[],
selectedEventId: MaybeString,
): ScopedRundownData {
const { hidePast } = useTimelineOptions();
const data = useMemo(() => {
@@ -97,11 +102,11 @@ export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeS
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const scopedRundown: PlayableEvent[] = [];
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: PlayableEvent | null = null;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
@@ -150,26 +155,28 @@ export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeS
}
type UpcomingEvents = {
now: OntimeEvent | null;
next: OntimeEvent | null;
followedBy: OntimeEvent | null;
now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null;
followedBy: ExtendedEntry<OntimeEvent> | null;
};
/**
* Returns upcoming events from current: now, next and followedBy
*/
export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeString): UpcomingEvents {
export function getUpcomingEvents(events: ExtendedEntry<PlayableEvent>[], selectedId: MaybeString): UpcomingEvents {
if (events.length === 0) {
return { now: null, next: null, followedBy: null };
}
let now = selectedId ? getEventWithId(events, selectedId) : null;
let now = selectedId ? (getEventWithId(events, selectedId) as ExtendedEntry<OntimeEvent>) : null;
if (!isOntimeEvent(now)) {
now = null;
}
const next = now ? getNextEvent(events, now.id)?.nextEvent : getFirstEvent(events).firstEvent;
const followedBy = next ? getNextEvent(events, next.id)?.nextEvent : null;
const next = now
? (getNextEvent(events, now.id)?.nextEvent as ExtendedEntry<OntimeEvent> | null)
: (getFirstEvent(events).firstEvent as ExtendedEntry<OntimeEvent> | null);
const followedBy = next ? (getNextEvent(events, next.id)?.nextEvent as ExtendedEntry<OntimeEvent> | null) : null;
// Return the titles, handling nulls appropriately
return {

Some files were not shown because too many files have changed in this diff Show More