Compare commits

...

129 Commits

Author SHA1 Message Date
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 00aa8a09fa refactor(roll breaking): make starting with roll backtrace planned times 2025-09-21 07:42:47 +02:00
Carlos Valente d04f958c11 fix: initialise group time 2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen 5716fc31cf feat: export package to help api consumers (#1624) 2025-09-21 07:42:47 +02:00
Carlos Valente a5d628af0c fix: prevent params drawer from being larger than screen 2025-09-21 07:42:47 +02:00
Carlos Valente 111a1f95ba fix: correct offset direction in expected time calculations 2025-09-21 07:42:47 +02:00
Carlos Valente 4bf74d86b3 refactor: set start metadata when starting from roll 2025-09-21 07:42:47 +02:00
arc-alex d2638d64e4 fix: group end elements should only send timertype data nothing else 2025-09-21 07:42:47 +02:00
arc-alex 02cddeec4d refactor: add a try grourd to the cell data generator to better pinpoint errors 2025-09-21 07:42:47 +02:00
Carlos Valente a8fe8b5f1e refactor: allow filtering schedule 2025-09-21 07:42:47 +02:00
Carlos Valente 8d2db7ffcd Relative mode (#1764)
* feat: add group start time to state

* refactor: calculate expected times when offset mode changes

* feat: show relative data in ui

* fixup! feat: add group start time to state

* fixup! refactor: calculate expected times when offset mode changes

* fixup! feat: show relative data in ui

* try to handle multiday

* show planed/expected rundown end day offset values

* refactor: style tweaks to timers

* refactor: cleanup data use

* day offset

* update tests

* test getExpectedStart multiday

* write start epoch to restore file

* current day in relative mode

* remove todo

* move find day offset to utils

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen 313590905f fix docker ignore (#1780) 2025-09-21 07:42:47 +02:00
Carlos Valente 7bbdab7dd9 fix(cuesheet): infinite render loop on resizing columns 2025-09-21 07:42:47 +02:00
Carlos Valente 304832ecde fix: maintain multiline in cuesheet cells 2025-09-21 07:42:47 +02:00
Carlos Valente 0ab4abfcfe docs: add contribution guidelines 2025-09-21 07:42:47 +02:00
Carlos Valente 1847822f31 fix: skip nested events 2025-09-21 07:42:47 +02:00
Carlos Valente 4499495212 feat: allow finding milestones 2025-09-21 07:42:47 +02:00
Carlos Valente 0035070db8 refactor: improve automated following 2025-09-21 07:42:47 +02:00
Carlos Valente 890a97d071 fix: allow moving a group after another 2025-09-21 07:42:47 +02:00
Carlos Valente 5441e5b07f refactor: improve pin styling 2025-09-21 07:42:47 +02:00
Carlos Valente ab4b94f2d8 fix: prevent layout reflow on different entry types 2025-09-21 07:42:47 +02:00
Carlos Valente fd7ccb4a5b refactor: add default value to secondary source 2025-09-21 07:42:47 +02:00
Carlos Valente 2e6bf234d5 fix: phase style overrides in timer 2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen c934b0ecdd cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen cd6d6f24eb fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-21 07:42:47 +02:00
arc-alex e632a08557 chore: bump version 2025-09-21 07:42:47 +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
180 changed files with 2745 additions and 1771 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
# Ignore build folders
node_modules
dist
**/node_modules
# 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

+18 -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,20 @@ 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
- name: Setup pnpm auth config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- 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
+46
View File
@@ -0,0 +1,46 @@
name: Ontime Resolver build
on:
release:
types: [published]
workflow_dispatch:
jobs:
build_resolver:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
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:
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 build:resolver
- name: Setup pnpm auth config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
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
+22 -9
View File
@@ -21,7 +21,7 @@ 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`
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
### Debugging backend
@@ -30,10 +30,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 +46,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 +66,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,13 +77,13 @@ 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.
Use the `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER
@@ -99,3 +99,16 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
## CONTRIBUTION GUIDELINES
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
For new PRs, please follow the following checklist:
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
* [ ] You have ran code formatting and linting in all your changes
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
* [ ] The PR body contains description and motivation for the changes
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
We will review as soon as possible
+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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-beta.2",
"version": "4.0.0-beta.5",
"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.2",
"version": "4.0.0-beta.5",
"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>
-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
*/
@@ -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);
@@ -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} />
@@ -11,9 +11,11 @@
}
.pin {
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
input {
font-size: 4rem;
@@ -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>
@@ -28,6 +28,7 @@
bottom: 0;
width: 40rem;
max-width: 100vw;
height: 100vh;
display: flex;
@@ -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]);
@@ -1,6 +1,5 @@
import { RefObject, useCallback, useEffect, useRef } from 'react';
import { useSelectedEventId } from './useSocket';
import { RefObject, useCallback, useEffect } from 'react';
import { MaybeString } from 'ontime-types';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
@@ -18,37 +17,26 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
// maintain current x scroll position
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
}
interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
if (!doFollow) {
if (!doFollow || !followTrigger) {
return;
}
@@ -60,16 +48,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
setScrollFlag?.(false);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) {
if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
@@ -77,32 +63,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
return scrollToRefComponent;
}
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
const selectedEvenId = useSelectedEventId();
const selectedRef = useRef<HTMLTableRowElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!doFollow) {
return;
}
if (selectedEvenId && selectedRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
snapToComponent(
{ current: selectedRef.current } as RefObject<HTMLElement>,
{ current: scrollRef.current } as RefObject<HTMLElement>,
topOffset,
);
});
}
}, [doFollow, selectedEvenId, topOffset]);
return {
selectedRef,
scrollRef,
};
}
+42 -39
View File
@@ -11,7 +11,6 @@ const createSelector =
export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => {
console.log('--- got', payload);
sendSocket('client', payload);
},
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
@@ -165,46 +164,15 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null,
}));
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
groupExpectedEnd: state.offset.expectedGroupEnd,
}));
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,
currentDay: state.eventNow?.dayOffset ?? 0,
currentDay: state.rundown.currentDay ?? 0,
actualStart: state.rundown.actualStart,
plannedStart: state.rundown.plannedStart,
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,
}));
@@ -228,6 +196,45 @@ export const usePlayback = () => {
return useRuntimeStore(featureSelector);
};
/* ======================= Overview data subscriptions ======================= */
export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
}));
export const useOffsetOverview = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
playback: state.timer.playback,
}));
export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 fo this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// we can force these numbers to 0 fo this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
/* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
@@ -269,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,19 +0,0 @@
export type OverridableOptions = {
keyColour?: string;
textColour?: string;
textBackground?: string;
font?: string;
size?: number;
justifyContent?: 'start' | 'center' | 'end';
alignItems?: 'start' | 'center' | 'end';
left?: string;
top?: string;
hideNav?: boolean;
hideOvertime?: boolean;
hideMessagesOverlay?: boolean;
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
hideTimerSeconds?: boolean;
removeLeadingZeros?: boolean;
};
@@ -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;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import {
ApiAction,
ApiActionTag,
Log,
MessageTag,
RefetchKey,
@@ -199,7 +199,7 @@ export const connectSocket = () => {
};
};
export function sendSocket<T extends MessageTag | ApiAction>(
export function sendSocket<T extends MessageTag | ApiActionTag>(
tag: T,
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
): void {
+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>
@@ -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,
);
@@ -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 };
}
+11 -12
View File
@@ -8,7 +8,7 @@ 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 { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
@@ -21,14 +21,14 @@ 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 style from './Operator.module.scss';
const selectedOffset = 50;
export default function Operator() {
const { data, status } = useRundown();
const { data, rundownMetadata, status } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
@@ -49,6 +49,7 @@ export default function Operator() {
scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
followTrigger: selectedEventId,
});
useWindowTitle('Operator');
@@ -112,7 +113,6 @@ export default function Operator() {
}
const canEdit = shouldEdit && subscribe.length;
const { process } = makeOperatorMetadata(selectedEventId);
return (
<div className={style.operatorContainer} data-testid='operator-view'>
@@ -129,8 +129,7 @@ export default function Operator() {
{data.order.map((entryId) => {
const entry = data.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;
@@ -157,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}
@@ -178,10 +177,10 @@ export default function Operator() {
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) {
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
@@ -206,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,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,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 };
}
@@ -47,9 +47,10 @@
.daySpan {
&::after {
content: '*';
content: "+"attr(data-day-offset);
vertical-align: super;
font-size: 0.75em;
font-size: 0.6em;
letter-spacing: 0;
color: $info-blue;
}
}
@@ -67,3 +68,10 @@
font-size: calc(1rem - 2px);
text-align: right;
}
.dueTime {
text-transform: capitalize;
font-size: 1rem;
letter-spacing: 0;
color: $playback-over;
}
@@ -8,30 +8,33 @@ import {
TbFolderPin,
TbFolderStar,
} from 'react-icons/tb';
import { OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types';
import { isPlaybackActive, millisToString } from 'ontime-utils';
import { OffsetMode, OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types';
import { dayInMs, isPlaybackActive, millisToString } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import {
useClock,
useCurrentGroupId,
useFlagTimerOverView,
useGroupTimerOverView,
useNextFlag,
useRundownOverview,
useRuntimePlaybackOverview,
useOffsetOverview,
useProgressOverview,
useStartTimesOverview,
useTimer,
} from '../../../common/hooks/useSocket';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import { calculateEndAndDaySpan, formattedTime } from '../overview.utils';
import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils';
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);
@@ -47,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>
@@ -58,8 +61,8 @@ export function StartTimes() {
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
{maybePlannedDaySpan > 0 ? (
<Tooltip
text={`Event spans over ${maybePlannedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} />}
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />}
>
{plannedEndText}
</Tooltip>
@@ -71,8 +74,8 @@ export function StartTimes() {
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
<Tooltip
text={`Event spans over ${maybeExpectedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} />}
text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
>
{formattedTime(maybeExpectedEnd)}
</Tooltip>
@@ -96,61 +99,110 @@ export function MetadataTimes() {
);
}
//TODO: there a some things here we still need to think about, mainly what to do whit the planed group duration in relation to the events
function GroupTimes() {
const { clock, groupExpectedEnd } = useRuntimePlaybackOverview();
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
const { currentGroupId } = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null;
// the group end time dose not encode any day offsets
const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null;
const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown);
const active = isPlaybackActive(playback);
// the group end time dose not encode any day offsets so it is calculated with group start time and duration
const plannedGroupEnd = (() => {
if (!active) return null;
if (!group || group.timeStart === null) return null;
const normalizedClock = clock + currentDay * dayInMs;
return mode === OffsetMode.Absolute
? group.timeStart + group.duration - normalizedClock
: actualGroupStart + group.duration - normalizedClock;
})();
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown);
const groupTitle = group?.title ?? null;
const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
return (
<div className={style.metadataRow}>
<span className={groupTitle ? style.labelTitle : style.label}>{`${groupTitle ? groupTitle : 'Group'} `}</span>
<span className={group?.title ? style.labelTitle : style.label}>{`${group?.title || 'Group'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} />
<span className={cx([style.time, !group && style.muted])}>{plannedTimeUntilGroupEnd}</span>
<span
className={cx([
style.time,
(!group || !active) && style.muted,
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
{plannedTimeUntilGroupEnd}
</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
<span className={cx([style.time, groupExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span>
<span
className={cx([
style.time,
!groupExpectedEnd && style.muted,
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
{expectedTimeUntilGroupEnd}
</span>
</div>
</div>
);
}
function FlagTimes() {
const { clock } = useClock();
const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null;
const plannedFlagStart = entry ? entry.timeStart - clock : null;
const plannedTimeUntilDisplay = formattedTime(plannedFlagStart, 3, TimerType.CountDown);
const active = isPlaybackActive(playback);
const plannedFlagStart = (() => {
if (!active) return null;
if (!entry) return null;
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
const normalizedClock = clock + currentDay * dayInMs;
return mode === OffsetMode.Absolute
? normalizedTimeStart - normalizedClock
: normalizedTimeStart + actualStart - plannedStart - normalizedClock;
})();
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
const expectedTimeUntilDisplay = formattedTime(expectedTimeUntil, 3, TimerType.CountDown);
const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
const title = entry?.title ?? null;
return (
<div className={style.metadataRow}>
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
<span className={title ? style.labelTitle : style.label}>{`${title || 'Flag'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>
<span
data-testid='flag-plannedStart'
className={cx([
style.time,
(!entry || !active) && style.muted,
plannedTimeUntilDisplay === 'due' && style.dueTime,
])}
>
{plannedTimeUntilDisplay}
</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>
<span
data-testid='flag-expectedStart'
className={cx([
style.time,
expectedTimeUntil === null && style.muted,
expectedTimeUntilDisplay === 'due' && style.dueTime,
])}
>
{expectedTimeUntilDisplay}
</span>
</div>
@@ -159,7 +211,7 @@ function FlagTimes() {
}
export function ProgressOverview() {
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview();
const { numEvents, selectedEventIndex } = useProgressOverview();
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
@@ -168,7 +220,7 @@ export function ProgressOverview() {
}
export function OffsetOverview() {
const { offset, playback } = useRuntimePlaybackOverview();
const { offset, playback } = useOffsetOverview();
const isPlaying = isPlaybackActive(playback);
const offsetState = getOffsetState(isPlaying ? offset : null);
@@ -3,6 +3,23 @@ import { dayInMs, millisToString } from 'ontime-utils';
import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
/**
* Composition to stop negative timers from being formatted
* They should show a due string instead
*
* This is used for cases when a negative timer is unwanted
* eg: count down to a milestone
*/
export function formatDueTime(
time: MaybeNumber,
segments: number = 3,
direction?: TimerType.CountDown | TimerType.CountUp,
dueString = 'due',
): string {
if (time !== null && time <= 0) return dueString;
return formattedTime(time, segments, direction);
}
/**
* Encapsulates the logic for formatting time in overview
*/
+7 -2
View File
@@ -82,7 +82,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: editorMode === AppMode.Run });
useFollowComponent({
followRef: cursorRef,
scrollRef,
doFollow: true,
followTrigger: editorMode === AppMode.Edit ? cursor : featureData?.selectedEventId,
});
// DND KIT
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
@@ -312,7 +317,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
setMetadata(rundownMetadata);
}, [order, entries, rundownMetadata]);
// in run mode, we follow selection
// in run mode, we follow the playback selection and open groups as needed
useEffect(() => {
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
@@ -28,20 +28,22 @@
padding-inline: 0;
box-shadow: $box-shadow-right;
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
min-width: 38rem;
max-width: 60rem;
max-width: none;
width: 0;
}
.side {
max-height: 100%;
max-width: 45rem;
margin: 0.5rem 0;
padding: 1rem;
padding-right: 0;
background-color: $gray-1325;
border-radius: 0 8px 8px 0;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
max-width: 45rem;
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
// width is locked to swatch picker elements
min-width: calc(15 * 2rem + 13 * 0.5rem);
width: 0;
}
@@ -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,
};
@@ -73,9 +73,7 @@ interface EventUntilProps {
isLinkedToLoaded: boolean;
}
function EventUntil(props: EventUntilProps) {
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: EventUntilProps) {
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
const isDue = timeUntil < MILLIS_PER_SECOND;
@@ -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,
};
@@ -41,9 +41,9 @@ export function canDrop(
order?: 'after' | 'before',
isTargetCollapsed?: boolean,
): boolean {
// this would mean inserting a group inside another
// inserting before would mean adding a group inside another
if (targetType === 'end-group') {
return false;
return order === 'after';
}
// this means swapping places with another group
@@ -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
@@ -17,7 +17,6 @@ $header-font-size: clamp(24px, 2.5vw, 48px);
// General styling
$accent-color: $red-500; // --accent-color-override
$delay-color: $ontime-delay-text;
$viewer-label-color: rgba(white, 25%);
// Main Properties of a viewer
@@ -43,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;
@@ -10,13 +10,14 @@ import {
makeProjectDataOptions,
} from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { scheduleOptions } from '../common/schedule/schedule.options';
import { getScheduleOptions } from '../common/schedule/schedule.options';
export const getBackstageOptions = (
timeFormat: string,
customFields: CustomFields,
projectData: ProjectData,
): ViewOption[] => {
const customFieldOptions = makeOptionsFromCustomFields(customFields, []);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
@@ -39,7 +40,7 @@ export const getBackstageOptions = (
},
],
},
scheduleOptions,
getScheduleOptions(customFieldOptions),
{
title: OptionTitle.ElementVisibility,
collapsible: true,
@@ -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}
/>
);
})}
@@ -1,21 +1,13 @@
import {
createContext,
PropsWithChildren,
RefObject,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
import { createContext, PropsWithChildren, RefObject, use, useEffect, useLayoutEffect, useRef, useState } from 'react';
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;
@@ -25,13 +17,18 @@ interface ScheduleContextState {
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
selectedEventId: string | null;
selectedEventId: EntryId | null;
}
export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren<ScheduleProviderProps>) => {
const { cycleInterval, stopCycle } = useScheduleOptions();
const { data: events } = usePartialRundown((event: OntimeEntry) => {
return isOntimeEvent(event);
const { cycleInterval, stopCycle, filter } = useScheduleOptions();
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;
return isOntimeEvent(entry) && Boolean(entry.custom[customKey]);
}
return isOntimeEvent(entry);
});
const [firstIndex, setFirstIndex] = useState(-1);
@@ -135,9 +132,9 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
selectedEventIndex = 0;
return (
<ScheduleContext.Provider
<ScheduleContext
value={{
events: viewEvents as OntimeEvent[],
events: viewEvents as ExtendedEntry<OntimeEvent>[],
selectedEventId,
numPages,
visiblePage,
@@ -145,12 +142,12 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
}}
>
{children}
</ScheduleContext.Provider>
</ScheduleContext>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
const context = use(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
@@ -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,116 +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(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
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(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
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(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props;
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(props: ExpectedTimeProps) {
const { time, delay } = props;
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} />;
}
@@ -1,14 +1,23 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { SelectOption } from '../../../common/components/select/Select';
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
import type { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
export const scheduleOptions: ViewOption = {
export const getScheduleOptions = (customFieldOptions: SelectOption[]): ViewOption => ({
title: OptionTitle.Schedule,
collapsible: true,
options: [
{
id: 'filter',
title: 'Filter',
description: 'Hide events without data in the selected custom field',
type: 'option',
values: customFieldOptions,
defaultValue: 'None',
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
@@ -31,9 +40,10 @@ export const scheduleOptions: ViewOption = {
defaultValue: false,
},
],
};
});
type ScheduleOptions = {
filter: string | null;
cycleInterval: number;
stopCycle: boolean;
showExpected: boolean;
@@ -41,6 +51,7 @@ type ScheduleOptions = {
function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
return {
filter: searchParams.get('filter'),
cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
stopCycle: isStringBoolean(searchParams.get('stopCycle')),
showExpected: isStringBoolean(searchParams.get('showExpected')),
@@ -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,
};
}
@@ -141,7 +141,7 @@ $item-height: 3.5rem;
}
.sub__schedule--delayed {
color: $delay-color;
color: $ontime-delay-text;
}
.sub__schedule--strike {
@@ -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);
@@ -53,6 +53,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
scrollRef,
doFollow: !lockAutoScroll,
topOffset: 0,
followTrigger: selectedEventId,
});
// reset scroll if nothing is selected
@@ -108,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",
"",
"",
],
]
`);
});
});
@@ -11,14 +11,14 @@ import {
import { ColumnDef } from '@tanstack/react-table';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import useColumnManager from '../cuesheet-table/useColumnManager';
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps {
columns: ColumnDef<ExtendedEntry>[];
}
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
const { columnOrder, saveColumnOrder } = useColumnManager(columns);
const { columnOrder, saveColumnOrder } = useColumnOrder(columns);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -13,14 +13,14 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import useColumnManager from './useColumnManager';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss';
@@ -79,8 +79,9 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
);
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns);
const { columnOrder, resetColumnOrder } = useColumnOrder(columns);
const { columnSizing, setColumnSizing } = useColumnSizes();
const { columnVisibility, setColumnVisibility } = useColumnVisibility();
const table = useReactTable({
data,
@@ -237,11 +238,14 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
}}
fixedHeaderContent={() => {
return table
.getHeaderGroups()
.map((headerGroup) => (
<CuesheetHeader key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />
));
return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues
return <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
});
}}
/>
@@ -7,7 +7,7 @@ import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { SortableCell } from './SortableCell';
import { Draggable, SortableCell, TableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss';
@@ -16,8 +16,9 @@ interface CuesheetHeaderProps {
cuesheetMode: AppMode;
}
export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
export function SortableCuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
return (
<tr key={headerGroup.id}>
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
@@ -43,8 +44,10 @@ export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHe
return (
<SortableCell
key={header.column.columnDef.id}
header={header}
columnId={header.column.id}
colSpan={header.colSpan}
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
@@ -54,3 +57,43 @@ export default function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHe
</tr>
);
}
export function CuesheetHeader({ headerGroup, cuesheetMode }: CuesheetHeaderProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
return (
<tr key={headerGroup.id}>
{cuesheetMode === AppMode.Edit && <th className={style.actionColumn} tabIndex={-1} />}
{!hideIndexColumn && (
<th className={style.indexColumn} tabIndex={-1}>
#
</th>
)}
{headerGroup.headers.map((header) => {
const customBackground = header.column.columnDef.meta?.colour;
const canWrite = header.column.columnDef.meta?.canWrite;
const customStyles: CSSProperties = {
opacity: canWrite ? 1 : 0.6,
};
if (customBackground) {
const customColour = getAccessibleColour(customBackground);
customStyles.backgroundColor = customColour.backgroundColor;
customStyles.color = customColour.color;
}
return (
<TableCell
key={header.column.columnDef.id}
columnId={header.column.id}
colSpan={header.colSpan}
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableCell>
);
})}
</tr>
);
}
@@ -4,3 +4,7 @@
padding-top: 0.25em;
width: 100%;
}
.multiline {
white-space: break-spaces;
}
@@ -2,6 +2,10 @@ import { PropsWithChildren } from 'react';
import style from './GhostedText.module.scss';
export default function GhostedText({ children }: PropsWithChildren) {
return <div className={style.ghostedText}>{children}</div>;
interface GhostedTextProps {
multiline?: boolean;
}
export default function GhostedText({ children, multiline }: PropsWithChildren<GhostedTextProps>) {
return <div className={`${style.ghostedText} ${multiline ? style.multiline : ''}`}>{children}</div>;
}
@@ -8,23 +8,22 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import style from '../CuesheetTable.module.scss';
interface SortableCellProps {
header: Header<ExtendedEntry, unknown>;
columnId: string;
colSpan: number;
injectedStyles: CSSProperties;
children: ReactNode;
draggable: ReactNode;
}
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
const { column, colSpan } = header;
export function SortableCell({ columnId, colSpan, injectedStyles, children, draggable }: SortableCellProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
id: columnId,
});
// build drag styles
const dragStyle = {
...injectedStyles,
zIndex: isDragging ? 2 : 'inherit',
cursor: isDragging ? 'grabbing' : 'grab',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -34,12 +33,31 @@ export function SortableCell({ header, injectedStyles, children }: SortableCellP
<div {...attributes} {...listeners}>
{children}
</div>
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={style.resizer}
/>
{draggable}
</th>
);
}
export function TableCell({ colSpan, injectedStyles, children, draggable }: SortableCellProps) {
return (
<th style={injectedStyles} colSpan={colSpan} tabIndex={-1}>
<div>{children}</div>
{draggable}
</th>
);
}
interface DraggableProps {
header: Header<ExtendedEntry, unknown>;
}
export function Draggable({ header }: DraggableProps) {
return (
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={style.resizer}
/>
);
}
@@ -143,7 +143,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return <MultiLineCell initialValue={initialValue as string} 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,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table';
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage';
@@ -14,17 +14,8 @@ const saveSizesToStorage = debounce((sizes: Record<string, number>) => {
localStorage.setItem(tableSizesKey, JSON.stringify(sizes));
}, 500);
export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({
key: tableHiddenKey,
defaultValue: {},
});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: tableOrderKey,
defaultValue: columns.map((col) => col.id as string),
});
const [columnSizing, setColumnSizingState] = useState(() => {
export function useColumnSizes() {
const [columnSizing, setColumnSizingState] = useState<Record<string, number>>(() => {
try {
const stored = localStorage.getItem(tableSizesKey);
return stored ? JSON.parse(stored) : {};
@@ -38,10 +29,22 @@ export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
saveSizesToStorage(columnSizing);
}, [columnSizing]);
const setColumnSizing = useCallback((sizes: typeof columnSizing) => {
setColumnSizingState(sizes);
const setColumnSizing = useCallback((sizesOrUpdater: Updater<ColumnSizingState>) => {
setColumnSizingState(sizesOrUpdater);
}, []);
return {
columnSizing,
setColumnSizing,
};
}
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[]) {
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: tableOrderKey,
defaultValue: columns.map((col) => col.id as string),
});
// update column order if columns change
useEffect(() => {
const newColumns = columns.map((col) => col.id as string);
@@ -55,12 +58,20 @@ export default function useColumnManager(columns: ColumnDef<ExtendedEntry>[]) {
}, [columns, saveColumnOrder]);
return {
columnVisibility,
columnOrder,
columnSizing,
resetColumnOrder,
setColumnVisibility,
saveColumnOrder,
setColumnSizing,
resetColumnOrder,
};
}
export function useColumnVisibility() {
const [columnVisibility, setColumnVisibility] = useLocalStorage({
key: tableHiddenKey,
defaultValue: {},
});
return {
columnVisibility,
setColumnVisibility,
};
}
@@ -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;
};
@@ -66,4 +66,5 @@
.scrollContainer {
max-height: 70vh;
overflow: auto;
padding-top: 1rem;
}
@@ -71,8 +71,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
results.map((entry, index) => {
const isSelected = selected === index;
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEntry.Event ? entry.cue : '';
const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
const displayCue = 'cue' in entry ? entry.cue : '';
return (
<li
@@ -83,7 +82,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': colour }}>
<div className={style.index} style={{ '--color': entry.colour }}>
{displayIndex}
</div>
<div className={style.cue}>{displayCue}</div>
@@ -99,7 +98,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
footerElements={
<div className={style.footer}>
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
<span className={style.em}>title</span> to filter search
<span className={style.em}>title</span> to filter search.
</div>
}
/>
@@ -1,6 +1,6 @@
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { EntryId, isOntimeEvent, isOntimeGroup, MaybeString, SupportedEntry } from 'ontime-types';
import { EntryId, isOntimeEvent, isOntimeGroup, isOntimeMilestone, MaybeString, SupportedEntry } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -9,14 +9,15 @@ const maxResults = 12;
type FilterableGroup = {
type: SupportedEntry.Group;
id: string;
id: EntryId;
index: number;
title: string;
colour: string;
};
type FilterableEvent = {
type: SupportedEntry.Event;
id: string;
id: EntryId;
index: number;
eventIndex: number;
title: string;
@@ -25,7 +26,17 @@ type FilterableEvent = {
parent: MaybeString;
};
type FilterableEntry = FilterableGroup | FilterableEvent;
type FilterableMilestone = {
type: SupportedEntry.Milestone;
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() {
const { data, rundownId } = useFlatRundown();
@@ -59,7 +70,7 @@ export default function useFinder() {
lastSearchString.current = searchValue;
if (searchValue.startsWith('index ')) {
const searchString = searchValue.replace('index ', '').trim();
const searchString = searchValue.slice('index '.length).trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
@@ -67,14 +78,14 @@ export default function useFinder() {
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.replace('cue ', '').trim();
const searchString = searchValue.slice('cue '.length).trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.replace('title ', '').trim();
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
@@ -162,33 +173,46 @@ export default function useFinder() {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.title.toLowerCase().includes(searchString)) {
const entry = data[i];
if (isOntimeEvent(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
id: entry.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
if (isOntimeGroup(event)) {
if (event.title.toLowerCase().includes(searchString)) {
} else if (isOntimeGroup(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Group,
id: event.id,
id: entry.id,
index: i,
title: event.title,
title: entry.title,
colour: entry.colour,
} satisfies FilterableGroup);
}
} else if (isOntimeMilestone(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Milestone,
id: entry.id,
index: i,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableMilestone);
}
}
}
return { results, error: null };
@@ -200,7 +224,7 @@ export default function useFinder() {
const select = useCallback(
(selectedEvent: FilterableEntry) => {
// First expand the parent group if this is an event inside a group
if (selectedEvent.type === SupportedEntry.Event && selectedEvent.parent !== null) {
if ('parent' in selectedEvent && selectedEvent.parent !== null) {
// Try direct state update instead of using callback
const currentGroups = [...new Set(collapsedGroups)];
const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent);
+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'>
@@ -105,7 +105,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
}
.delay {
color: $delay-color;
color: $ontime-delay-text;
}
.timeOverview {
@@ -117,7 +117,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
.cross {
text-decoration: line-through;
text-decoration-thickness: 2px;
text-decoration-color: $delay-color;
text-decoration-color: $ontime-delay-text;
}
.separeLeft {
+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;
}

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