Compare commits

..

26 Commits

Author SHA1 Message Date
Claude e28b06efed refactor(cuesheet): reserve the space an image will actually take
We were remembering the aspect ratio and reserving the full width of the
column, which over-reserves for an image smaller than the column: the row
would settle to a smaller size once the image was shown.

We now remember the size of the image and express the reservation in CSS
as min(100%, width), which is what the image itself resolves to at any
column width. Column sizes are applied as CSS variables and do not
re-render the cells, so the reservation follows a resize on its own.

Also groups the tests around the behaviour they describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-08-30 10:52:39 +00:00
Claude 9efac0a60a refactor(cuesheet): images are referenced by link only
A path to a local file resolves on the machine running ontime, but not
for the clients we serve the cuesheet to, so we no longer accept it.

Validation now parses the value as a URL and checks the protocol, which
also rejects malformed values that the previous prefix check let through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-08-30 10:37:25 +00:00
Claude 0ebad09154 Merge remote-tracking branch 'origin/claude/cuesheet-image-unload-scroll-k26i4u' into claude/cuesheet-image-unload-scroll-k26i4u
# Conflicts:
#	apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx
2026-08-29 14:03:29 +00:00
Claude f3cd541ef0 fix(cuesheet): report image problems instead of failing silently
A value which is not a link was discarded on blur without any feedback:
the text stayed in the field, so it looked like it had been saved. We now
mark the field and say what is expected. Paths served by ontime itself
(eg. /user/slide.png) are now accepted, they were rejected before.

An image which cannot be loaded showed a broken icon with no explanation.
This is what a dropbox share link does, since it serves an html page
rather than the image, so it is worth naming the problem.

We also reserve the space of an image while it is loading, using the
aspect ratio of the last time we saw it. Rows are unmounted while out of
view, so without it the row collapses and grows again on the way back,
shifting the table under the user.

Smaller items in the same cell: the lazy loading attribute only added a
gate before the request (the row is only mounted when it is already close
to the viewport), the image had no alt text, and two expressions could
never run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-08-29 14:02:34 +00:00
Carlos Valente 9ec12f4927 docs(llm): request for useeffect documentation 2026-08-28 12:29:12 +02:00
Carlos Valente 4c08ca2908 feat(timer): playing sound on time end 2026-08-28 12:29:12 +02:00
Carlos Valente 265b167600 bump version to 4.13.0 2026-08-28 12:04:02 +02:00
Carlos Valente eb7b2a5c4b refactor(finder): improve feature UX
Search every text field with optional filters, keep result selection stable, and expose clearer match context and counts. Cover query parsing, matching, mixed-entry indexing, and the reveal flow.
2026-08-28 11:41:44 +02:00
Carlos Valente e0ae9f7f09 refactor(log): use ToggleButton for filters
Reuse the shared toggle abstraction while preserving the existing filter behaviour and accessible labels.
2026-08-28 11:41:44 +02:00
Carlos Valente b0c778fa62 feat(buttons): add ToggleButton component
Keep toggle styling and the accessible pressed state coupled in one reusable button.
2026-08-28 11:41:44 +02:00
Carlos Valente 98d3b1f6f3 chore(license): resolver has permissive license 2026-08-28 08:40:17 +02:00
Carlos Valente d9dd147d4b refactor(server): replace defensive copies with cow patterns 2026-08-26 20:17:34 +02:00
Carlos Valente 9806f97d27 test(rundown): improve rundown utils coverage 2026-08-25 22:41:37 +02:00
Carlos Valente b9683f00dc fix: prevent stale rundown cache on rename 2026-08-25 21:41:34 +02:00
Carlos Valente 63fdc50c50 docs(llms): document ongoing code migrations 2026-08-25 21:41:18 +02:00
Carlos Valente 5acfc4a122 docs(llms): setup agents documentation 2026-08-25 21:41:18 +02:00
Carlos Valente a84b8b5530 refactor(ui): polish navigation menu and view params editor 2026-08-23 13:42:21 +02:00
Carlos Valente c19aa4ca28 fix(cuesheet): prevent stale column width on refresh 2026-08-23 13:28:24 +02:00
Carlos Valente 805663e2ea chore(deps): migrate to @tanstack/react-table v9 2026-08-23 13:28:24 +02:00
Carlos Valente 0f0b32d444 accept bearer token 2026-08-21 20:52:11 +02:00
Carlos Valente 703dee35a4 style: bump oxfmt to 0.63 and reformat 2026-08-18 13:56:53 +02:00
Carlos Valente 2a7f5b7872 chore(deps): upgrade dependencies 2026-08-18 13:56:53 +02:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
Claude 7ec1179ede fix(cuesheet): avoid layout shifts when images scroll back into view
The cuesheet is a virtualised table: rows are unmounted once they leave
the viewport, taking the <img> elements with them. On the way back the
image has no dimensions until the browser makes it available, so the row
grows under the user as it loads.

We now remember the aspect ratio of the images we have seen and use it to
reserve the space the image will take. This holds two numbers per image:
the image data itself is left to the browser cache, which is better
placed than us to decide when memory should be released.

Also drops the lazy loading attribute: the row is only mounted when it is
already close to the viewport, so it only adds a gate before the request.

Whether the image is fetched again on scroll back is decided by the cache
headers of the host serving it, and cannot be worked around from here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
2026-07-25 19:28:58 +00:00
114 changed files with 4432 additions and 3064 deletions
+46
View File
@@ -0,0 +1,46 @@
---
name: code-review
description: Review Ontime changes for concrete correctness, architecture, testing, routing, security, and maintenance issues. For GitHub Copilot review.
---
# Review Ontime changes
Review as a maintainer. Find material defects, not speculative advice, style preferences, or generic checklists. Approve when no material issue remains.
## Load relevant context
Read the full diff, PR description, linked issue, tests, and nearby owning code. Then load only applicable guides:
- Every non-trivial review: [change assessment](../../../docs/agent-guides/change-assessment.md)
- Module placement, server layers, client state, shared packages: [architecture](../../../docs/agent-guides/architecture.md)
- Tests or changed behaviour: [testing](../../../docs/agent-guides/testing.md)
- Comments, abstractions, naming, complexity: [code quality](../../../docs/agent-guides/code-quality.md)
- Authentication, external input, files, integrations, assets, secrets: [security](../../../docs/agent-guides/security.md)
- Routes, URLs, websockets, authentication, cookies, presets, assets: [routing and cloud](../../../docs/agent-guides/routing-and-cloud.md)
- Rundowns, timers, persistence, imports, cache, realtime: [domain invariants](../../../docs/agent-guides/domain-invariants.md)
- Commands, imports, dependencies, formatting, CI claims: [workflow](../../../docs/agent-guides/workflow.md)
Check a guide and nearby canonical code before citing an Ontime convention. Skip unrelated guides.
## Review order
1. Establish intent and affected runtime surfaces.
2. Read tests first. Identify claimed behaviour and coverage.
3. Trace implementation, errors, and state transitions.
4. Check correctness/data integrity, security, architecture/testability, cloud routing, lifecycle/performance, maintainability.
5. Verify claimed checks. Never claim unobserved results.
Passing tests do not prove architecture, routing, comments, or error paths. Review changed behaviour only; include existing problems only when the diff worsens or relies on them.
## Findings
Report only concrete, actionable issues. Each finding: tight line range, direct defect, impact and trigger, smallest viable remedy when unclear.
- **P0 — Critical:** data loss, exploitable vulnerability, broadly broken production. Blocks merge.
- **P1 — High:** likely correctness failure or major supported-deployment regression. Blocks merge.
- **P2 — Medium:** real edge-case defect, architecture regression, missing business-rule test, stale comment, meaningful maintenance risk. Normally blocks merge.
- **P3 — Low:** local improvement with limited impact. No subjective style or tool-managed formatting.
Order by priority. Prefer few high-confidence findings. No praise or checklist before findings. If none, say so and note verification gaps or residual risk.
Then give one concise PR-level value/risk/complexity assessment. Never repeat it per finding.
+22
View File
@@ -0,0 +1,22 @@
# Ontime agent guide
Make the smallest maintainable change. Keep scope narrow. Inspect nearby code first. Reuse helpers and boundaries when semantics match.
## Load only relevant guides
- Commands, validation, formatting, imports, PRs: [workflow](docs/agent-guides/workflow.md).
- Non-trivial planning, implementation, review: [change assessment](docs/agent-guides/change-assessment.md).
- Module placement, server layers, client state, shared packages: [architecture](docs/agent-guides/architecture.md).
- Tests or changed behaviour: [testing](docs/agent-guides/testing.md).
- Comments, naming, abstractions, maintainability: [code quality](docs/agent-guides/code-quality.md).
- Authentication, external input, files, integrations, assets, secrets: [security](docs/agent-guides/security.md).
- Navigation, URLs, API paths, websockets, redirects, cookies, static assets: [routing and cloud](docs/agent-guides/routing-and-cloud.md).
- Rundowns, timers, imports, persistence, cache, websockets: [domain invariants](docs/agent-guides/domain-invariants.md).
Load multiple guides when needed. Skip unrelated guides for mechanical work.
## Before handoff
- Check the final diff for scope, stale comments, temporary code, redundant tests, generated files.
- If work reveals a missing, stable, reusable system, domain, or product invariant, update its owning guide. Exclude guesses, one-off bugs, and implementation details.
- Follow [workflow verification](docs/agent-guides/workflow.md). Report only observed results.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.12.0", "version": "4.13.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+9 -8
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.12.0", "version": "4.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.6.0", "@base-ui/react": "1.7.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^9.5.1",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^9.1.2",
"@uiw/codemirror-theme-vscode": "^4.25.10", "@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.18.0", "axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.6.0", "react-icons": "5.7.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -60,7 +60,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.1.1", "@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -72,8 +73,8 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.0.1", "vite": "8.2.1",
"vite-plugin-compression2": "2.5.1", "vite-plugin-compression2": "2.5.3",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.5.0",
"vitest": "catalog:" "vitest": "catalog:"
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
import { ComponentProps } from 'react';
import Button from './Button';
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
/** whether the option this button controls is currently on */
pressed: boolean;
};
/**
* A button which carries an on / off state.
*
* Keeps the pressed styling and the accessible state together, so that a toggle
* cannot end up looking active without also announcing that it is.
*/
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
}
@@ -0,0 +1,6 @@
.eyebrow {
font-size: $aux-text-size;
font-weight: 400;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Eyebrow.module.scss';
interface EyebrowProps {
className?: string;
}
export default function Eyebrow({ children, className }: PropsWithChildren<EyebrowProps>) {
return <span className={cx([style.eyebrow, className])}>{children}</span>;
}
@@ -1,7 +1,3 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -23,14 +19,15 @@
width: 22rem; width: 22rem;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-right: 1px solid $gray-1100; border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -50,22 +47,51 @@
} }
.header { .header {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 3.5rem; gap: 1rem;
padding-inline: 1.5rem; min-height: 4rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
} }
.body { .body {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-block: 0.5rem 1rem;
} }
.note { .group {
margin-left: auto; display: flex;
color: $white-20; flex-direction: column;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
} }
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo } from 'react'; import { memo, PropsWithChildren } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
@@ -14,9 +14,11 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink'; import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation'; import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses'; import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -33,7 +35,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreenDocument();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
@@ -52,75 +54,93 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} /> <RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Ontime</Dialog.Title> <div className={style.headerText}>
<IconButton variant='subtle-white' size='large' onClick={onClose}> <Dialog.Title className={style.title}>Ontime</Dialog.Title>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
{supportsFullscreen && ( <MenuGroup label='This screen'>
<NavigationMenuItem active={fullscreen} onClick={toggle}> {supportsFullscreen && (
Toggle Fullscreen <NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
{fullscreen ? <IoContract /> : <IoExpand />} )}
<NavigationMenuToggle
checked={mirror}
icon={<IoSwapVertical />}
label='Flip Screen'
onToggle={() => toggleMirror()}
/>
{canUseWakeLock && (
<NavigationMenuToggle
checked={keepAwake}
icon={<LuCoffee />}
label='Keep Awake'
onToggle={toggleKeepAwake}
/>
)}
<NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
Rename Client
</NavigationMenuItem> </NavigationMenuItem>
)} </MenuGroup>
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{canUseWakeLock && (
<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} /> <MenuGroup label='Ontime app'>
<EditorNavigation />
<EditorNavigation />
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink <ClientLink
key={route.url} to='cuesheet'
to={route.url} current={location.pathname === '/cuesheet'}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined} postAction={isSmallScreen ? onClose : undefined}
> >
{route.label} <IoLockClosedOutline />
Cuesheet
</ClientLink> </ClientLink>
))} <ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Operator
</ClientLink>
</MenuGroup>
<MenuGroup label='Views'>
{navigatorConstants.map((route) => (
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
</ClientLink>
))}
</MenuGroup>
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} /> <PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup> </Dialog.Popup>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
); );
} }
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.group}>
<div className={style.groupLabel}>
<Eyebrow>{label}</Eyebrow>
</div>
{children}
</div>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) { function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation(); const location = useLocation();
const { data: urlPresets } = useUrlPresets(); const { data: urlPresets } = useUrlPresets();
@@ -129,8 +149,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null; if (navPresets.length === 0) return null;
return ( return (
<> <MenuGroup label='Presets'>
<hr className={style.separator} />
{navPresets.map((preset) => ( {navPresets.map((preset) => (
<ClientLink <ClientLink
key={preset.alias} key={preset.alias}
@@ -141,6 +160,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias} {preset.alias}
</ClientLink> </ClientLink>
))} ))}
</> </MenuGroup>
); );
} }
@@ -1,5 +1,5 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo, useCallback, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams'; import { hasCustomParams } from '../../stores/savedViewParams';
@@ -15,21 +15,37 @@ interface ViewNavigationMenuProps {
suppressSettings?: boolean; suppressSettings?: boolean;
} }
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.closest(
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
) !== null
);
}
export default memo(ViewNavigationMenu); export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams); const hasSavedChanges = hasCustomParams(searchParams);
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
useHotkeys([ useHotkeys([
[ [
'Space', 'Space',
() => { (event) => {
if (isNavigationLocked) return; if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
menuHandler.toggle(); event.preventDefault();
toggleMenu();
}, },
{ preventDefault: true }, { preventDefault: false },
], ],
[ [
'mod + ,', 'mod + ,',
@@ -48,11 +64,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : toggleMenu}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges} hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
</> </>
); );
} }
@@ -2,35 +2,68 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.75rem 1.5rem; padding: 0.625rem 1.25rem;
gap: 0.5rem; gap: 0.625rem;
width: 100%; width: 100%;
border-left: 4px solid transparent; border-left: 3px solid transparent;
color: $action-text-color; color: $gray-200;
font-size: $text-body-size;
white-space: nowrap; white-space: nowrap;
text-align: left;
cursor: pointer; cursor: pointer;
transition-property: color; transition-property: color, background-color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
// keep icons on a fixed box so labels align regardless of the glyph
> svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
color: $gray-500;
transition: color $transition-time-action;
}
&:hover { &:hover {
color: $ontime-color; background-color: $white-3;
background-color: $gray-1350; color: $ui-white;
> svg {
color: $gray-300;
}
} }
&:active { &:active {
background-color: $border-color-ondark; background-color: $white-7;
} }
&:focus { &:focus-visible {
outline: 2px solid $blue-500; outline: 2px solid $blue-500;
background-color: $gray-1350; outline-offset: -2px;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
} }
&.current { &.current {
background-color: $gray-1300; background-color: $bg-active;
border-left: 4px solid $action-text-color; border-left-color: $blue-400;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
} }
} }
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { isKeyEnter } from '../../../utils/keyEvent'; import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss'; import style from './NavigationMenuItem.module.scss';
@@ -11,6 +11,7 @@ interface NavigationMenuItemProps {
onClick: () => void; onClick: () => void;
} }
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({ export default function NavigationMenuItem({
active, active,
className, className,
@@ -24,7 +25,9 @@ export default function NavigationMenuItem({
role='button' role='button'
onClick={onClick} onClick={onClick}
onKeyDown={(event) => { onKeyDown={(event) => {
if (isKeyEnter(event)) { if (isKeyEnter(event) || isKeySpace(event)) {
event.preventDefault();
event.stopPropagation();
onClick(); onClick();
} }
}} }}
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import Switch from '../../switch/Switch';
import style from './NavigationMenuItem.module.scss';
interface NavigationMenuToggleProps {
checked: boolean;
icon: ReactNode;
label: string;
onToggle: () => void;
}
/** A menu row which reflects, and toggles, an on/off state */
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
return (
<label className={style.link}>
{icon}
<span className={style.label}>{label}</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</label>
);
}
@@ -1,22 +1,52 @@
.footer {
flex: 0 0 auto;
padding: 0.75rem 0;
border-top: 1px solid $white-10;
}
.header { .header {
font-size: calc(1rem - 2px); padding: 0 1.25rem 0.25rem;
margin-left: 1rem;
color: $gray-700; color: $gray-700;
} }
.interfaces { .interfaces {
padding: 0.5rem 0.5rem; padding: 0.25rem 1.25rem 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.25rem;
}
.interface {
min-width: 0;
max-width: 100%;
> div {
max-width: 100%;
> button:first-child {
min-width: 0;
max-width: calc(100% - 1.5rem);
justify-content: flex-start;
padding-inline: 0.5rem;
}
}
} }
.interfaceCopy { .interfaceCopy {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.goIcon { .goIcon {
flex: 0 0 auto;
@include rotate-fourty-five; @include rotate-fourty-five;
margin-left: 0.25rem; margin-left: 0.25rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -3,6 +3,8 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo'; import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils'; import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag'; import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss'; import style from './OtherAddresses.module.scss';
@@ -12,32 +14,33 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) { export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo(); const { data } = useInfo();
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
// there is no point showing this if we only have one interface if (externalInterfaces.length === 0) {
if (data.networkInterfaces.length < 2) {
return null; return null;
} }
return ( return (
<> <div className={style.footer}>
<div className={style.header}>Accessible on external networks</div> <div className={style.header}>
<Eyebrow>Also available at</Eyebrow>
</div>
<div className={style.interfaces}> <div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => { {externalInterfaces.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation); const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'> <div key={nif.name} className={style.interface}>
<span className={style.interfaceCopy}> <CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
{nif.address} <IoArrowUp className={style.goIcon} /> <span className={style.interfaceCopy}>
</span> <span className={style.address}>{nif.address}</span>
</CopyTag> <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
</div>
); );
})} })}
</div> </div>
</> </div>
); );
} }
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vitest';
import { getExternalInterfaces } from '../otherAddresses.utils';
describe('getExternalInterfaces', () => {
test('returns no alternatives when localhost is the only interface', () => {
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
});
test('does not treat an interface name as deployment information', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'cloud', address: '192.168.1.42' },
]),
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
});
test('returns non-local interfaces when local and external interfaces are available', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]),
).toEqual([
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]);
});
});
@@ -0,0 +1,6 @@
import { type NetworkInterface } from 'ontime-types';
/** Returns network addresses other than the localhost address injected by the info endpoint. */
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
}
@@ -2,6 +2,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
} }
// attempt to match with ontimeTextInputs // attempt to match with ontimeTextInputs
@@ -1,37 +1,77 @@
.inline { .multiOptionList {
display: inline-flex; overflow-y: auto;
align-items: center; max-height: 12rem;
flex-wrap: wrap; background: $gray-1200;
gap: 1rem; border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
// styles from subtle button
.toggleSelect { .toggleSelect {
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.75rem;
padding-inline: 0.5rem; min-height: 2.5rem;
height: 2.5rem; padding: 0.5rem 0.75rem;
background: $gray-1050; background: transparent;
color: $ui-white; border: 0;
color: $gray-300;
line-height: 1em; line-height: 1em;
border-radius: $component-border-radius-md; cursor: pointer;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
&:hover:not(:disabled):not(:active) { &:hover {
background: $gray-1000; background: $gray-1000;
color: $blue-500; color: $ui-white;
} }
&:active:not(:disabled) { &:active {
background: $gray-1100; background: $gray-1100;
border-color: $gray-1250; }
&:focus-within {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
&.selected {
background: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
}
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
> [role='checkbox'] {
flex: 0 0 auto;
width: 1.125rem;
height: 1.125rem;
border: 1px solid $gray-400;
&[data-unchecked] {
background: $gray-1100;
}
&[data-checked] {
border-color: $blue-400;
}
&:focus-visible {
outline: 0;
}
} }
&::after { &::after {
content: ''; content: '';
margin-left: 0.25rem; flex: 0 0 auto;
width: 0.75em; margin-left: auto;
height: 0.75em; width: 0.625rem;
height: 0.625rem;
background: var(--user-bg, $gray-900); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
@@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils'; import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -107,18 +108,20 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return ( return (
<> <>
<input name={id} hidden readOnly value={paramState.join(',')} /> <input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.inline}> <div className={style.multiOptionList}>
{values.map((option) => { {values.map((option) => {
const selected = paramState.includes(option.value);
return ( return (
<label <label
key={option.value} key={option.value}
className={style.toggleSelect} className={cx([style.toggleSelect, selected && style.selected])}
style={{ style={{
'--user-bg': option.colour, '--user-bg': option.colour,
}} }}
> >
<Checkbox <Checkbox
checked={paramState.includes(option.value)} checked={selected}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)} onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/> />
{option.label} {option.label}
@@ -1,13 +1,3 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -34,11 +24,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $gray-1250; background-color: $ui-black;
color: $ui-white; color: $ui-white;
border-left: 1px solid $gray-1100; border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -58,36 +48,54 @@
} }
.header { .header {
padding-inline: 1rem; flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 3.5rem; gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
} }
.body { .body {
flex: 1; flex: 1;
padding-inline: 1rem; padding: 1rem;
padding-bottom: 10vh;
overflow-y: auto; overflow-y: auto;
} }
.footer { .footer {
flex: 0 0 auto;
display: flex; display: flex;
padding-inline: 1rem; padding: 0.75rem 1rem;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
} }
.sectionList { .sectionList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 0.75rem;
padding-right: 0.5rem;
} }
.info { .info {
@@ -4,6 +4,7 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams'; import { useSavedViewParams } from '../../stores/savedViewParams';
@@ -71,8 +72,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} /> <Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Customise</Dialog.Title> <div className={style.headerText}>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}> <Dialog.Title className={style.title}>Customise</Dialog.Title>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
@@ -1,10 +1,21 @@
.presetSection { .presetSection {
background-color: $gray-1350; background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
margin-bottom: 0.75rem;
}
.header {
padding: 0.5rem 1rem;
min-height: 2.25rem;
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.25rem;
padding: 1rem 0.5rem; color: $gray-300;
margin-bottom: 1rem; }
.presetList {
border-top: 1px solid $white-7;
max-height: 10rem; max-height: 10rem;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
@@ -14,12 +25,39 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-height: 2.5rem;
padding: 0.375rem 1rem;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
&.active { &.active {
color: $blue-500; background-color: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
} }
} }
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions { .presetActions {
margin-left: auto; margin-left: auto;
} }
.current {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
color: $blue-400;
font-size: $aux-text-size;
font-weight: 600;
}
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets'; import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss'; import style from './ViewParamsPresets.module.scss';
@@ -26,22 +27,31 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return ( return (
<div className={style.presetSection}> <div className={style.presetSection}>
{viewPresets.map((preset) => { <div className={style.header}>
const active = searchParams.get('alias') === preset.alias; <Eyebrow>Saved presets</Eyebrow>
return ( </div>
<div key={preset.alias} className={cx([style.preset, active && style.active])}> <div className={style.presetList}>
<div>{preset.alias}</div> {viewPresets.map((preset) => {
<Button const active = searchParams.get('alias') === preset.alias;
variant={active ? 'ghosted' : 'subtle-white'} return (
onClick={() => handleRecall(preset)} <div key={preset.alias} className={cx([style.preset, active && style.active])}>
disabled={active} <span className={style.presetName}>{preset.alias}</span>
className={style.presetActions} {active ? (
> <span className={style.current}>Current</span>
{active ? 'Applied' : 'Apply'} ) : (
</Button> <Button
</div> variant='ghosted'
); size='small'
})} onClick={() => handleRecall(preset)}
className={style.presetActions}
>
Apply
</Button>
)}
</div>
);
})}
</div>
</div> </div>
); );
} }
@@ -1,48 +1,118 @@
.section { .section {
overflow: hidden;
color: $ui-white; color: $ui-white;
font-size: 1rem; font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
.sectionHeader { .sectionHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
height: 2rem; gap: 0.5rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem;
background: transparent;
border: 0;
font: inherit;
text-align: left;
color: $gray-300;
&.collapsible { &.collapsible {
cursor: pointer; cursor: pointer;
transition-property: color, background-color;
transition-duration: $transition-time-action;
&:hover {
background-color: $white-3;
color: $ui-white;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
} }
} }
.sectionTitle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/** marks a section which holds values coming from the URL */
.customised {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background-color: $active-indicator;
flex-shrink: 0;
}
.chevron {
flex-shrink: 0;
rotate: 180deg;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&.closed {
rotate: 0deg;
}
}
.options {
padding-inline: 1rem;
border-top: 1px solid $white-7;
}
.label { .label {
margin-top: $section-spacing; display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray;
display: flex; &:not(:last-child) {
flex-direction: column; border-bottom: 1px solid $white-7;
gap: 0.25rem; }
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
} }
.title { .title {
font-size: $inner-section-text-size; color: $ui-white;
display: block;
width: 100%;
} }
.description { .description {
font-size: $inner-section-text-size; color: $gray-400;
display: block; font-size: $aux-text-size;
color: $modal-note-color; line-height: 1.4;
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
} }
.hidden { .hidden {
@@ -1,7 +1,9 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants'; import { OptionTitle } from './constants';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types'; import { type ParamField } from './viewParams.types';
@@ -14,50 +16,74 @@ interface ViewParamsSectionProps {
options: ParamField[]; options: ParamField[];
} }
/** Fields which are compact enough to share a row with their label */
function isInlineField(option: ParamField): boolean {
return option.type === 'boolean' || option.type === 'colour';
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) { export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
const handleCollapse = () => { // hidden options are inputs without a UI, there is nothing to frame them with
if (collapsible) { if (title === OptionTitle.Hidden) {
setCollapsed((prev) => !prev); return <HiddenContents options={options} />;
} }
};
const isCollapsed = Boolean(collapsible && collapsed);
return ( return (
<section className={style.section}> <section className={style.section}>
{title === OptionTitle.Hidden ? ( <SectionHeader
<HiddenContents options={options} /> title={title}
) : ( isCustomised={options.some((option) => searchParams.has(option.id))}
<> collapsed={isCollapsed}
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}> onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
{title} />
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />} {/* collapsed options stay mounted: the form reads its values from the DOM */}
</div> <div className={cx([style.options, isCollapsed && style.hidden])}>
<SectionContents options={options} collapsed={collapsed} /> {options.map((option) => (
</> <label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
)}
</section>
);
}
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
return (
<>
{options.map((option) => {
return (
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<span className={style.title}>{option.title}</span> <span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span> <span className={style.description}>{option.description}</span>
<ParamInput paramField={option} /> <ParamInput paramField={option} />
</label> </label>
); ))}
})} </div>
</> </section>
);
}
interface SectionHeaderProps {
title: string;
isCustomised: boolean;
collapsed: boolean;
/** a header without a toggle belongs to a section which cannot collapse */
onToggle?: () => void;
}
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
const label = (
<Eyebrow className={style.sectionTitle}>
{title}
{isCustomised && <span className={style.customised} title='Contains custom values' />}
</Eyebrow>
);
if (!onToggle) {
return <div className={style.sectionHeader}>{label}</div>;
}
return (
<button
type='button'
className={cx([style.sectionHeader, style.collapsible])}
aria-expanded={!collapsed}
onClick={onToggle}
>
{label}
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
</button>
); );
} }
@@ -0,0 +1,26 @@
import { getRememberedDimensions, rememberDimensions } from '../imageDimensions';
/** stand-in for a loaded HTMLImageElement */
function makeImage(naturalWidth: number, naturalHeight: number) {
return { naturalWidth, naturalHeight } as HTMLImageElement;
}
test('We remember the size of an image, so that we can reserve its space when it comes back', () => {
expect(getRememberedDimensions('http://ontime.local/unseen.png')).toBe(null);
rememberDimensions('http://ontime.local/image.png', makeImage(1920, 1080));
expect(getRememberedDimensions('http://ontime.local/image.png')).toMatchObject({ width: 1920, height: 1080 });
// an image which failed to load has no size to offer
rememberDimensions('http://ontime.local/broken.png', makeImage(0, 0));
expect(getRememberedDimensions('http://ontime.local/broken.png')).toBe(null);
});
test('We keep the most recently seen images, older entries are forgotten', () => {
for (let i = 0; i < 600; i++) {
rememberDimensions(`http://ontime.local/${i}.png`, makeImage(100, 50));
}
expect(getRememberedDimensions('http://ontime.local/0.png')).toBe(null);
expect(getRememberedDimensions('http://ontime.local/599.png')).toMatchObject({ width: 100, height: 50 });
});
@@ -0,0 +1,49 @@
/**
* Images in the cuesheet live inside a virtualised table:
* rows are unmounted when they leave the viewport and mounted again when they come back.
* A re-mounted image has no dimensions until it is available,
* which makes the row change height and the table shift under the user.
*
* We remember the size of the images we have already seen
* so that we can reserve the space they will take.
* This only holds two numbers per image: we leave the image data itself to the browser cache,
* which knows better than us when memory should be released.
*/
export interface ImageDimensions {
width: number;
height: number;
}
/** how many sizes we remember, this is only a few bytes per entry */
const maxSize = 500;
const dimensions = new Map<string, ImageDimensions>();
/**
* @returns the size of a previously loaded image, if we have seen it before
*/
export function getRememberedDimensions(src: string): ImageDimensions | null {
return dimensions.get(src) ?? null;
}
/**
* Records the size of a loaded image
*/
export function rememberDimensions(src: string, image: HTMLImageElement) {
if (image.naturalHeight === 0) {
return;
}
// the map iteration order is our LRU queue, re-adding the entry marks it as recently used
dimensions.delete(src);
dimensions.set(src, { width: image.naturalWidth, height: image.naturalHeight });
while (dimensions.size > maxSize) {
const oldest = dimensions.keys().next();
if (oldest.done) {
return;
}
dimensions.delete(oldest.value);
}
}
+4
View File
@@ -4,6 +4,10 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter'; return event.key === 'Enter';
} }
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
return event.key === ' ' || event.code === 'Space';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
-35
View File
@@ -1,5 +1,3 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' { declare module '*.scss' {
const content: Record<string, string>; const content: Record<string, string>;
export default content; export default content;
@@ -32,39 +30,6 @@ declare global {
} }
} }
/**
* Declare custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*
* And metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
canWrite: boolean;
colour?: string;
}
}
/** /**
* Allow passing CSS Properties * Allow passing CSS Properties
*/ */
@@ -53,7 +53,7 @@ ul {
&.active { &.active {
color: $ui-white; color: $ui-white;
border-left-color: $blue-400; border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
font-weight: 600; font-weight: 600;
} }
@@ -79,7 +79,7 @@ ul {
&.active { &.active {
color: $ui-white; color: $ui-white;
border-left-color: $blue-400; border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
font-weight: 600; font-weight: 600;
} }
} }
@@ -124,7 +124,7 @@ $card-padding: 2rem;
// active rows use a panel-local class today; keep the treatment here so all // active rows use a panel-local class today; keep the treatment here so all
// settings tables share the same state without each panel owning its colours // settings tables share the same state without each panel owning its colours
tr[class*='current'] { tr[class*='current'] {
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
> td:first-child { > td:first-child {
box-shadow: inset 3px 0 $blue-400; box-shadow: inset 3px 0 $blue-400;
@@ -200,8 +200,7 @@ $card-padding: 2rem;
.overlay { .overlay {
position: absolute; position: absolute;
z-index: $zindex-backdrop; z-index: $zindex-backdrop;
width: 100%; inset: 0;
height: 100%;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
display: grid; display: grid;
place-content: center; place-content: center;
@@ -0,0 +1,7 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals'; import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() { export default function AppVersion() {
const { data, isError } = useAppVersion(); const { data, isError } = useAppVersion();
@@ -18,7 +20,12 @@ export default function AppVersion() {
return ( return (
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title={`Ontime ${appVersion}`} title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={ description={
isOntimeCloud isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.` ? `Version ${data.version} is available. Restart your stage to update.`
@@ -26,7 +33,7 @@ export default function AppVersion() {
} }
/> />
{!isOntimeCloud && ( {!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink> <ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)} )}
</Panel.ListItem> </Panel.ListItem>
); );
@@ -12,20 +12,21 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import { viewLabels } from '../../../../../viewerConfig';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions'; import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
const targetOptions: SelectOption<OntimeViewPresettable>[] = [ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.Cuesheet, label: 'Cuesheet' }, { value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
{ value: OntimeView.Operator, label: 'Operator' }, { value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
{ value: OntimeView.Timer, label: 'Timer' }, { value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
{ value: OntimeView.Backstage, label: 'Backstage' }, { value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
{ value: OntimeView.Timeline, label: 'Timeline' }, { value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
{ value: OntimeView.StudioClock, label: 'Studio Clock' }, { value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
{ value: OntimeView.Countdown, label: 'Countdown' }, { value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
{ value: OntimeView.ProjectInfo, label: 'Project Info' }, { value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
]; ];
const formId = 'url-preset-form'; const formId = 'url-preset-form';
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>} {rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && ( {data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info> <Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)} )}
+19 -30
View File
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import ToggleButton from '../../common/components/buttons/ToggleButton';
import { clearLogs, useLogData } from '../../common/stores/logger'; import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -55,72 +56,60 @@ export default function Log() {
<div className={cx([style.container, isExtracted && style.extracted])}> <div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}> <Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span> <span className={style.filterLabel}>Filter by</span>
<Button <ToggleButton
variant={showUser ? 'primary' : 'subtle'} pressed={showUser}
size='small' size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)} onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)} onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.User} {LogOrigin.User}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showClient ? 'primary' : 'subtle'} pressed={showClient}
size='small' size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)} onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)} onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Client} {LogOrigin.Client}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showServer ? 'primary' : 'subtle'} pressed={showServer}
size='small' size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)} onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)} onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Server} {LogOrigin.Server}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showPlayback ? 'primary' : 'subtle'} pressed={showPlayback}
size='small' size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)} onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)} onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Playback} {LogOrigin.Playback}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showRx ? 'primary' : 'subtle'} pressed={showRx}
size='small' size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)} onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)} onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Rx} {LogOrigin.Rx}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showTx ? 'primary' : 'subtle'} pressed={showTx}
size='small' size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)} onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)} onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Tx} {LogOrigin.Tx}
</Button> </ToggleButton>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}> <Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear <IoClose /> Clear
</Button> </Button>
@@ -8,10 +8,16 @@ export default memo(FinderPlacement);
function FinderPlacement() { function FinderPlacement() {
const [isOpen, handler] = useDisclosure(); const [isOpen, handler] = useDisclosure();
useHotkeys([ /**
['mod + f', handler.toggle, { preventDefault: true }], * The empty tagsToIgnore is significant: by default the hook skips input elements,
['Escape', handler.close, { preventDefault: true }], * which would make the shortcut dead while editing an entry.
]); *
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
* search again" rather than "close it". The finder selects its input instead, and
* Escape closes.
*/
useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
if (isOpen) { if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />; return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,6 +312,7 @@ export default function RundownEvent({
onClick={handleFocusClick} onClick={handleFocusClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
data-testid='rundown-event' data-testid='rundown-event'
data-selected={isSelected}
{...(isPlaying ? { 'data-running': true } : {})} {...(isPlaying ? { 'data-running': true } : {})}
> >
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} /> <RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
@@ -1,15 +1,14 @@
import type { ColumnDef } from '@tanstack/react-table';
import type { CustomFields } from 'ontime-types'; import type { CustomFields } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import type { CuesheetColumnDef } from '../../../views/cuesheet/cuesheet-table/cuesheetTable.features';
/** /**
* Creates column definitions for the rundown table * Creates column definitions for the rundown table
* Reuses cuesheetColsFactory with preset=undefined for full access * Reuses cuesheetColsFactory with preset=undefined for full access
*/ */
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] { export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true // When preset=undefined, factory defaults to fullRead=true, fullWrite=true
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run) // canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
return makeCuesheetColumns(customFields, AppMode.Edit, undefined); return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
+1
View File
@@ -48,6 +48,7 @@ $playback-under: $green-500;
// interface panels // interface panels
$bg-container-l1: $gray-1350; $bg-container-l1: $gray-1350;
$bg-container-l2: $gray-1300; $bg-container-l2: $gray-1300;
$bg-active: rgba($action-blue, 0.16);
$backdrop-color: rgba(0, 0, 0, 0.7); $backdrop-color: rgba(0, 0, 0, 0.7);
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0; $box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
+25 -7
View File
@@ -1,12 +1,30 @@
export const navigatorConstants = [ import { OntimeView } from 'ontime-types';
{ url: 'timer', label: 'Timer' },
{ url: 'backstage', label: 'Backstage' }, /** User facing name of each view, the single source for view naming in the UI */
{ url: 'timeline', label: 'Timeline' }, export const viewLabels: Record<OntimeView, string> = {
{ url: 'studio', label: 'Studio Clock' }, [OntimeView.Editor]: 'Editor',
{ url: 'countdown', label: 'Countdown' }, [OntimeView.Cuesheet]: 'Cuesheet',
{ url: 'info', label: 'Project Info' }, [OntimeView.Operator]: 'Operator',
[OntimeView.Timer]: 'Timer',
[OntimeView.Backstage]: 'Backstage',
[OntimeView.Timeline]: 'Timeline',
[OntimeView.StudioClock]: 'Studio Clock',
[OntimeView.Countdown]: 'Countdown',
[OntimeView.ProjectInfo]: 'Project Info',
};
/** Views offered in the navigation menu, in the order they are shown. Their value is also their route */
const navigatorViews = [
OntimeView.Timer,
OntimeView.Backstage,
OntimeView.Timeline,
OntimeView.StudioClock,
OntimeView.Countdown,
OntimeView.ProjectInfo,
]; ];
export const navigatorConstants = navigatorViews.map((view) => ({ url: view, label: viewLabels[view] }));
// default time format to use for users in 12 hour clocks // default time format to use for users in 12 hour clocks
export const FORMAT_12 = 'h:mm:ss a'; export const FORMAT_12 = 'h:mm:ss a';
// default time format to use for users in 24 hour clocks // default time format to use for users in 24 hour clocks
@@ -0,0 +1,18 @@
import { isValidImageSource } from '../cuesheet-table/cuesheet-table-elements/EditableImage';
test('An image is referenced by link, anything else is rejected', () => {
const testCases = [
{ value: 'https://example.com/image.png', isValid: true },
{ value: 'http://example.com/image.png', isValid: true },
// a file is local to the machine running ontime, it would not resolve for the clients we serve
{ value: '/user/image.png', isValid: false },
{ value: 'file:///Users/me/image.png', isValid: false },
{ value: 'C:\\images\\image.png', isValid: false },
// values which do not describe a location we can reach
{ value: 'www.example.com/image.png', isValid: false },
{ value: 'https://', isValid: false },
{ value: 'some text', isValid: false },
];
testCases.forEach((t) => expect(isValidImageSource(t.value)).toBe(t.isValid));
});
@@ -7,14 +7,13 @@ import {
useSensor, useSensor,
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
import { useColumnOrder } from '../cuesheet-table/useColumnManager'; import { useColumnOrder } from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps { interface CuesheetDndProps {
columns: ColumnDef<ExtendedEntry>[]; columns: CuesheetColumnDef[];
tableRoot?: 'editor' | 'cuesheet'; tableRoot?: 'editor' | 'cuesheet';
} }
@@ -1,5 +1,5 @@
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { useTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
@@ -29,12 +29,17 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow'; import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import TableMenu from './cuesheet-table-menu/TableMenu'; import TableMenu from './cuesheet-table-menu/TableMenu';
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar'; import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
import {
CuesheetColumnDef,
CuesheetTable as CuesheetTableInstance,
cuesheetTableFeatures,
} from './cuesheetTable.features';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager'; import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = { type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[]; columns: CuesheetColumnDef[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
source: RundownSource; source: RundownSource;
insertElement?: ReactNode; insertElement?: ReactNode;
@@ -120,7 +125,8 @@ export default function CuesheetTable({
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot); const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot); const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
const table = useReactTable({ const table = useTable({
features: cuesheetTableFeatures,
data: flatRundown, data: flatRundown,
columns, columns,
columnResizeMode: 'onChange', columnResizeMode: 'onChange',
@@ -131,7 +137,6 @@ export default function CuesheetTable({
}, },
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing, onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
meta, meta,
}); });
@@ -195,7 +200,7 @@ export default function CuesheetTable({
return colSizes; return colSizes;
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need // eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation // eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
}, [table.getState().columnSizingInfo, table.getState().columnSizing]); }, [columns, table.state.columnResizing, table.state.columnSizing]);
const allLeafColumns = table.getAllLeafColumns(); const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel(); const { rows } = table.getRowModel();
@@ -214,9 +219,7 @@ export default function CuesheetTable({
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []); const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
const fixedHeaderContent = useCallback(() => { const fixedHeaderContent = useCallback(() => {
return table.getHeaderGroups().map((headerGroup) => { return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues // if the table is being resized, we render non-sortable headers to avoid performance issues
return ( return (
@@ -279,8 +282,8 @@ interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number }; columnSizeVars: { [key: string]: number };
cursor: string | null; cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners']; listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows']; rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
table: Table<ExtendedEntry>; table: CuesheetTableInstance;
handleAddNew?: (type: SupportedEntry) => void; handleAddNew?: (type: SupportedEntry) => void;
} }
@@ -1,16 +1,16 @@
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { HeaderGroup, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
import { Draggable, SortableCell, TableCell } from './SortableCell'; import { Draggable, SortableCell, TableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps { interface CuesheetHeaderProps {
headerGroup: HeaderGroup<ExtendedEntry>; headerGroup: CuesheetHeaderGroup;
cuesheetMode: AppMode; cuesheetMode: AppMode;
hideIndexColumn: boolean; hideIndexColumn: boolean;
} }
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }} injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />} draggable={<Draggable header={header} />}
> >
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : <FlexRender header={header} />}
</SortableCell> </SortableCell>
); );
})} })}
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }} injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />} draggable={<Draggable header={header} />}
> >
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : <FlexRender header={header} />}
</TableCell> </TableCell>
); );
})} })}
@@ -5,6 +5,19 @@
&:not(:read-only):hover::placeholder { &:not(:read-only):hover::placeholder {
opacity: 1; opacity: 1;
} }
&[data-invalid] {
outline: 1px solid $red-500;
}
}
/** feedback on a value we cannot use, either rejected or failed to load */
.message {
display: block;
padding: 0.25rem 0;
color: $red-500;
font-size: calc(1rem - 3px);
line-height: 1.2;
} }
.imageCell { .imageCell {
@@ -1,27 +1,54 @@
import { memo } from 'react'; import { memo, useState } from 'react';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import { getRememberedDimensions, rememberDimensions } from '../../../../common/utils/imageDimensions';
import style from './EditableImage.module.scss'; import style from './EditableImage.module.scss';
interface EditableImageProps { interface EditableImageProps {
initialValue: string; initialValue: string;
fieldLabel: string;
readOnly?: boolean; readOnly?: boolean;
updateValue: (newValue: string) => void; updateValue: (newValue: string) => void;
} }
export default memo(EditableImage); export default memo(EditableImage);
function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) { /**
* Images are referenced by link: anything local to the machine running ontime
* would not resolve for the clients we serve the cuesheet to
*/
export function isValidImageSource(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: EditableImageProps) {
const [isRejected, setIsRejected] = useState(false);
/** we keep track of the source itself, so that the state follows the value being shown */
const [failedSource, setFailedSource] = useState<string | null>(null);
const [loadedSource, setLoadedSource] = useState<string | null>(null);
const handleUpdate = (newValue: string) => { const handleUpdate = (newValue: string) => {
if (newValue === initialValue) { const value = newValue.trim();
if (value === initialValue) {
setIsRejected(false);
return; return;
} }
if (newValue !== '' && !newValue.startsWith('http')) {
if (value !== '' && !isValidImageSource(value)) {
setIsRejected(true);
return; return;
} }
updateValue(newValue);
setIsRejected(false);
updateValue(value);
}; };
const openInNewTab = () => { const openInNewTab = () => {
@@ -36,22 +63,42 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
if (!initialValue) { if (!initialValue) {
return ( return (
<Input <>
variant='ghosted' <Input
className={style.imageInput} variant='ghosted'
fluid className={style.imageInput}
placeholder='Paste image URL' fluid
onBlur={(event) => handleUpdate(event.currentTarget.value)} placeholder='Paste image URL'
onKeyDown={(event) => { data-invalid={isRejected || undefined}
if (event.key === 'Enter') { onChange={() => setIsRejected(false)}
handleUpdate(event.currentTarget.value); onBlur={(event) => handleUpdate(event.currentTarget.value)}
} onKeyDown={(event) => {
}} if (event.key === 'Enter') {
defaultValue={initialValue} handleUpdate(event.currentTarget.value);
/> }
}}
/>
{isRejected && <span className={style.message}>Images are referenced by link (https://...)</span>}
</>
); );
} }
/**
* The cuesheet is virtualised: rows are unmounted once they leave the viewport.
* When the row comes back, we reserve the space the image took
* so that the table does not shift while the browser makes it available.
* The reservation is given in CSS so that it follows the column being resized,
* the same way the image itself does once it is shown.
*/
const knownDimensions = getRememberedDimensions(initialValue);
const isLoaded = loadedSource === initialValue;
const reservedSpace = knownDimensions
? {
aspectRatio: knownDimensions.width / knownDimensions.height,
width: `min(100%, ${knownDimensions.width}px)`,
}
: undefined;
return ( return (
<div className={style.imageCell}> <div className={style.imageCell}>
{!readOnly && ( {!readOnly && (
@@ -62,7 +109,22 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
</Button> </Button>
</div> </div>
)} )}
{Boolean(initialValue) && <img loading='lazy' src={initialValue} className={style.image} />} {failedSource === initialValue ? (
<span className={style.message}>Could not load image</span>
) : (
<img
src={initialValue}
alt={fieldLabel}
className={style.image}
onLoad={(event) => {
rememberDimensions(initialValue, event.currentTarget);
setLoadedSource(initialValue);
}}
onError={() => setFailedSource(initialValue)}
/** until the image is available, we reserve the space it took the last time we saw it */
style={isLoaded ? undefined : reservedSpace}
/>
)}
</div> </div>
); );
} }
@@ -1,14 +1,14 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types'; import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react'; import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './EventRow.module.scss'; import style from './EventRow.module.scss';
@@ -25,7 +25,7 @@ interface EventRowProps {
skip: boolean; skip: boolean;
parent: EntryId | null; parent: EntryId | null;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry<OntimeEntry>>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -133,7 +133,7 @@ function EventRow({
data-testid={`cuesheet-cell-${cell.column.id}`} data-testid={`cuesheet-cell-${cell.column.id}`}
data-column-id={cell.column.id} data-column-id={cell.column.id}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} <FlexRender cell={cell} />
</td> </td>
); );
})} })}
@@ -1,12 +1,12 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import { CSSProperties, memo } from 'react'; import { CSSProperties, memo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './GroupRow.module.scss'; import style from './GroupRow.module.scss';
@@ -15,7 +15,7 @@ interface GroupRowProps {
colour: string; colour: string;
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -76,7 +76,7 @@ function GroupRow({
}} }}
role='cell' role='cell'
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} <FlexRender cell={cell} />
</td> </td>
); );
})} })}
@@ -1,14 +1,14 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react'; import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './MilestoneRow.module.scss'; import style from './MilestoneRow.module.scss';
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
colour: string; colour: string;
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -102,7 +102,7 @@ function MilestoneRow({
}} }}
tabIndex={-1} tabIndex={-1}
> >
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())} {canRender && <FlexRender cell={cell} />}
</td> </td>
); );
})} })}
@@ -1,9 +1,8 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { CSSProperties, ReactNode } from 'react'; import { CSSProperties, ReactNode } from 'react';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { CuesheetHeaderCell } from '../cuesheetTable.features';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
@@ -48,7 +47,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
} }
interface DraggableProps { interface DraggableProps {
header: Header<ExtendedEntry, unknown>; header: CuesheetHeaderCell;
} }
export function Draggable({ header }: DraggableProps) { export function Draggable({ header }: DraggableProps) {
@@ -1,4 +1,3 @@
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types'; import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -8,6 +7,7 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time'; import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies'; import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
import DurationInput from './DurationInput'; import DurationInput from './DurationInput';
import EditableImage from './EditableImage'; import EditableImage from './EditableImage';
import FlagCell from './FlagCell'; import FlagCell from './FlagCell';
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string { function getColumnLabel(column: CuesheetCellContext['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id; return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
} }
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
); );
} }
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
); );
} }
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntr
); );
} }
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
); );
} }
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function LazyImage({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -175,10 +175,17 @@ function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>)
const canWrite = column.columnDef.meta?.canWrite; const canWrite = column.columnDef.meta?.canWrite;
const initialValue = event.custom[column.id]; const initialValue = event.custom[column.id];
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />; return (
<EditableImage
initialValue={initialValue}
fieldLabel={getColumnLabel(column)}
updateValue={update}
readOnly={!canWrite}
/>
);
} }
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -207,7 +214,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
); );
} }
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) { function MakeFlagField({ row }: CuesheetCellContext) {
const event = row.original; const event = row.original;
if (!isOntimeEvent(event) || !event.flag) { if (!isOntimeEvent(event) || !event.flag) {
return null; return null;
@@ -215,7 +222,7 @@ function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
return <FlagCell />; return <FlagCell />;
} }
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeCustomField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -255,8 +262,8 @@ export function makeCuesheetColumns(
customFields: CustomFields, customFields: CustomFields,
cuesheetMode: AppMode, cuesheetMode: AppMode,
preset: URLPreset | undefined, preset: URLPreset | undefined,
): ColumnDef<ExtendedEntry>[] { ): CuesheetColumnDef[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = []; const columnsDef: CuesheetColumnDef[] = [];
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode); const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
if (canRead('flag')) { if (canRead('flag')) {
@@ -2,7 +2,6 @@ import { Popover } from '@base-ui/react/popover';
import { Toggle } from '@base-ui/react/toggle'; import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group'; import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar'; import { Toolbar } from '@base-ui/react/toolbar';
import type { Column } from '@tanstack/react-table';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5'; import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
@@ -10,9 +9,9 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox'; import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover'; import PopoverContents from '../../../../common/components/popover/Popover';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetPermissions } from '../../useTablePermissions';
import type { CuesheetColumn } from '../cuesheetTable.features';
import CuesheetShareModal from './CuesheetShareModal'; import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss'; import style from './CuesheetTableSettings.module.scss';
@@ -37,7 +36,7 @@ type TableModeControls = {
}; };
interface CuesheetTableHeaderToolbarProps { interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[]; columns: CuesheetColumn[];
optionsStore: TableHeaderOptionsStore; optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
@@ -111,7 +110,7 @@ interface ViewSettingsProps {
} }
interface ColumnSettingsProps { interface ColumnSettingsProps {
columns: Column<ExtendedEntry, unknown>[]; columns: CuesheetColumn[];
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
@@ -203,7 +202,7 @@ function ColumnSettings({
return ( return (
<Editor.Label key={`${column.id}-${visible}`} className={style.option}> <Editor.Label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} /> <Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
{columnHeader as ReactNode} {columnHeader as ReactNode}
</Editor.Label> </Editor.Label>
); );
@@ -12,7 +12,7 @@
transition: background-color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out;
&[data-background-rundown='true'] { &[data-background-rundown='true'] {
background-color: rgba($ontime-color, 0.5); background-color: $bg-active;
.apart::before { .apart::before {
content: 'BACKGROUND EDIT'; content: 'BACKGROUND EDIT';
@@ -0,0 +1,74 @@
import {
columnOrderingFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
metaHelper,
tableFeatures,
} from '@tanstack/react-table';
import type { CellContext, Column, ColumnDef, Header, HeaderGroup, Table } from '@tanstack/react-table';
import type { TimeField } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import type { AppMode } from '../../../ontimeConfig';
/**
* Custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*/
export interface CuesheetTableMeta {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
/**
* Metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
export interface CuesheetColumnMeta {
canWrite: boolean;
colour?: string;
}
/**
* Features registered in the cuesheet and rundown tables.
* In v9 an API only exists once its feature is registered, so this list is the
* source of truth for what the table can do:
* - `columnOrderingFeature`: user reorders columns by dragging the headers
* - `columnVisibilityFeature`: user toggles columns in the table settings
* - `columnSizingFeature`: column widths, exposed to CSS as custom properties
* - `columnResizingFeature`: the drag handle in the header (requires sizing)
*
* The `tableMeta` / `columnMeta` slots replace the v8 global module augmentation:
* they scope our meta types to this table instead of every table in the app.
*/
export const cuesheetTableFeatures = tableFeatures({
columnOrderingFeature,
columnVisibilityFeature,
columnSizingFeature,
columnResizingFeature,
tableMeta: metaHelper<CuesheetTableMeta>(),
columnMeta: metaHelper<CuesheetColumnMeta>(),
});
export type CuesheetFeatures = typeof cuesheetTableFeatures;
/** Convenience aliases so consumers do not need to repeat the feature generic */
export type CuesheetColumnDef = ColumnDef<CuesheetFeatures, ExtendedEntry>;
export type CuesheetTable = Table<CuesheetFeatures, ExtendedEntry>;
export type CuesheetCellContext = CellContext<CuesheetFeatures, ExtendedEntry>;
export type CuesheetHeaderGroup = HeaderGroup<CuesheetFeatures, ExtendedEntry>;
export type CuesheetHeaderCell = Header<CuesheetFeatures, ExtendedEntry, unknown>;
export type CuesheetColumn = Column<CuesheetFeatures, ExtendedEntry, unknown>;
@@ -1,10 +1,10 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table'; import { ColumnSizingState, Updater } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { debounce } from '../../../common/utils/debounce'; import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage'; import { makeStageKey } from '../../../common/utils/localStorage';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { CuesheetColumnDef } from './cuesheetTable.features';
type TableRoot = 'editor' | 'cuesheet'; type TableRoot = 'editor' | 'cuesheet';
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
}; };
} }
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') { export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]); const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({ const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
@@ -3,11 +3,14 @@
.error { .error {
padding-inline: 0.5rem; padding-inline: 0.5rem;
font-size: 1rem; font-size: 1rem;
height: 3rem; // rows grow when a match is shown from a note or custom field
min-height: 3rem;
padding-block: 0.35rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem;
} }
.entry[data-selected='true'] { .entry[data-selected='true'] {
@@ -18,21 +21,47 @@
color: $label-gray; color: $label-gray;
} }
.more {
padding-inline: 0.5rem;
padding-block: 0.75rem;
font-size: calc(1rem - 2px);
color: $label-gray;
border-top: 1px solid $gray-1000;
text-align: center;
}
.error { .error {
color: $error-red; color: $error-red;
} }
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding-top: 0.75rem;
}
.filterLabel {
font-size: calc(1rem - 3px);
color: $label-gray;
margin-right: 0.15rem;
}
.data { .data {
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'index cue' 'index cue'
'index title'; 'index title'
'index match';
column-gap: 1rem; column-gap: 1rem;
grid-template-rows: min-content 1fr; grid-template-rows: min-content 1fr;
min-width: 0;
.index { .index {
grid-area: index; grid-area: index;
background-color: var(--color, $gray-1000); // background and text colour come from getAccessibleColour, which keeps the
// number legible whatever colour the user gave the entry
border-radius: 2px; border-radius: 2px;
padding-block: 0.25rem; padding-block: 0.25rem;
width: 3.5rem; width: 3.5rem;
@@ -42,14 +71,33 @@
.title { .title {
grid-area: title; grid-area: title;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.cue { .cue {
grid-area: cue; grid-area: cue;
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
color: $label-gray; color: $label-gray;
max-height: 1em;
min-height: 0; min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match {
grid-area: match;
font-size: calc(1rem - 3px);
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchLabel {
color: $ui-white;
margin-right: 0.4rem;
} }
} }
@@ -63,13 +111,14 @@
color: $label-gray; color: $label-gray;
} }
.filterHint { .count {
text-align: right; text-align: right;
white-space: nowrap;
} }
.em { .go {
color: $ui-white; white-space: nowrap;
margin-inline: 0.25rem; padding-left: 1rem;
} }
.hints { .hints {
@@ -98,7 +147,7 @@
flex-direction: column; flex-direction: column;
} }
.filterHint { .count {
text-align: left; text-align: left;
} }
} }
+117 -52
View File
@@ -1,11 +1,12 @@
import { useDebouncedCallback } from '@mantine/hooks'; import { MaybeString } from 'ontime-types';
import { SupportedEntry } from 'ontime-types'; import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
import { KeyboardEvent, useState } from 'react';
import ToggleButton from '../../../common/components/buttons/ToggleButton';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd'; import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import useFinder from './useFinder'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import useFinder, { FinderResult } from './useFinder';
import style from './Finder.module.scss'; import style from './Finder.module.scss';
@@ -15,46 +16,76 @@ interface FinderProps {
} }
export default function Finder({ isOpen, onClose }: FinderProps) { export default function Finder({ isOpen, onClose }: FinderProps) {
const { find, select, results, error } = useFinder(); const [search, setSearch] = useState('');
const [selected, setSelected] = useState(0); const [filter, setFilter] = useState<MaybeString>(null);
const [selectedId, setSelectedId] = useState<MaybeString>(null);
const debouncedFind = useDebouncedCallback(find, 100); /**
* Keeps typing responsive while the list re-renders.
* The search itself is cheap, rendering the results is what costs.
*/
const deferredSearch = useDeferredValue(search);
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
const inputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef<HTMLLIElement>(null);
/**
* We track the selection by ID so that it survives the result list changing under us:
* an entry that no longer exists falls back to the first result instead of dangling past the end
*/
const activeIndex = Math.max(
0,
results.findIndex((entry) => entry.id === selectedId),
);
const activeEntry = results.at(activeIndex);
/** keep the highlighted entry in view while navigating with the keyboard */
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' });
}, [activeEntry?.id]);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => { const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// pressing the search shortcut again selects the query, ready to be replaced
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
inputRef.current?.select();
return;
}
// all operations need results // all operations need results
if (results.length === 0) { if (results.length === 0) {
return; return;
} }
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
setSelected((prev) => (prev + 1) % results.length); setSelectedId(results[(activeIndex + 1) % results.length].id);
} }
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
setSelected((prev) => (prev - 1 + results.length) % results.length); setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
submit(); submit(activeEntry);
} }
}; };
const submit = () => { const submit = (entry: FinderResult | undefined) => {
const selectedEvent = results[selected]; if (!entry) {
select(selectedEvent); return;
}
select(entry);
onClose(); onClose();
}; };
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => { /** Scopes the search to a single field, or back to all fields when tapped again */
const target = event.target as HTMLElement; const handleFilter = (filterKey: string) => {
const li = target.closest('li'); setFilter((previous) => (previous === filterKey ? null : filterKey));
if (li) { inputRef.current?.focus();
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
}; };
const hiddenResults = total - results.length;
return ( return (
<Modal <Modal
title='' title=''
@@ -63,35 +94,68 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
showBackdrop showBackdrop
bodyElements={ bodyElements={
<div onKeyDown={navigate}> <div onKeyDown={navigate}>
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' /> <Input
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}> ref={inputRef}
height='large'
fluid
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder='Search...'
/>
<div className={style.filters} data-testid='finder-filters'>
<span className={style.filterLabel}>Filter by</span>
{filters.map((option) => (
<ToggleButton
key={option.key}
pressed={appliedFilter === option.key}
size='small'
onClick={() => handleFilter(option.key)}
>
{option.label}
</ToggleButton>
))}
</div>
<ul className={style.scrollContainer}>
{error && <li className={style.error}>{error}</li>} {error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>} {!error && results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 && {results.map((entry) => {
results.map((entry, index) => { const isSelected = activeEntry?.id === entry.id;
const isSelected = selected === index; // the title and cue are already on the row, a match anywhere else needs showing
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-'; const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue';
const displayCue = 'cue' in entry ? entry.cue : '';
return ( return (
<li <li
key={entry.id} key={entry.id}
className={style.entry} ref={isSelected ? activeRef : undefined}
data-selected={isSelected} className={style.entry}
data-index={index} data-testid='finder-result'
onClick={submit} data-selected={isSelected}
> onClick={() => submit(entry)}
<div className={style.data}> onPointerEnter={() => setSelectedId(entry.id)}
<div className={style.index} style={{ '--color': entry.colour }}> >
{displayIndex} <div className={style.data}>
</div> <div className={style.index} style={getAccessibleColour(entry.colour)}>
<div className={style.cue}>{displayCue}</div> {entry.eventIndex ?? '-'}
<div className={style.title}>{entry.title}</div>
</div> </div>
{isSelected && <span>Go </span>} <div className={style.cue}>{entry.cue}</div>
</li> <div className={style.title}>{entry.title}</div>
); {showMatch && (
})} <div className={style.match} data-testid='finder-result-match'>
<span className={style.matchLabel}>{entry.match?.label}</span>
{entry.match?.excerpt}
</div>
)}
</div>
{isSelected && <span className={style.go}>Go </span>}
</li>
);
})}
{hiddenResults > 0 && (
<li className={style.more} data-testid='finder-more'>
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} keep typing to narrow the search
</li>
)}
</ul> </ul>
</div> </div>
} }
@@ -112,10 +176,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
Close Close
</span> </span>
</div> </div>
<div className={style.filterHint}> {total > 0 && (
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or <div className={style.count} data-testid='finder-count'>
<span className={style.em}>title</span> {hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
</div> </div>
)}
</div> </div>
} }
/> />
@@ -0,0 +1,162 @@
import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types';
import { parseQuery, searchByIndex, searchByText } from './useFinder';
function makeEvent(id: string, overrides: Partial<OntimeEvent> = {}): OntimeEvent {
return {
type: SupportedEntry.Event,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeEvent;
}
function makeGroup(id: string, overrides: Partial<OntimeGroup> = {}): OntimeGroup {
return {
type: SupportedEntry.Group,
id,
title: '',
note: '',
colour: '#000000',
custom: {},
...overrides,
} as OntimeGroup;
}
function makeMilestone(id: string, overrides: Partial<OntimeMilestone> = {}): OntimeMilestone {
return {
type: SupportedEntry.Milestone,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeMilestone;
}
function makeDelay(id: string): OntimeDelay {
return { type: SupportedEntry.Delay, id, duration: 1000, parent: null };
}
describe('parseQuery()', () => {
const filters = [
{ key: 'cue', label: 'Cue' },
{ key: 'Camera_Notes', label: 'Camera Notes' },
];
it.each([
['cue 12', { filterKey: 'cue', searchString: '12' }],
['cue:12', { filterKey: 'cue', searchString: '12' }],
['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }],
])('parses the field prefix in %s', (searchValue, expected) => {
expect(parseQuery(searchValue, filters)).toStrictEqual(expected);
});
it('keeps an unprefixed query as a search across all fields', () => {
expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' });
});
it('recognises a filter before any search text has been entered', () => {
expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' });
});
});
describe('searchByText()', () => {
const customFields: CustomFields = {
Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' },
Slide: { type: 'image', label: 'Slide', colour: '#000000' },
};
it('searches cue, title, note, and text custom fields in rundown order', () => {
const data = [
makeMilestone('milestone', { cue: 'needle' }),
makeGroup('group', { title: 'needle' }),
makeEvent('note', { note: 'find the needle here' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([
{ id: 'milestone', field: 'cue' },
{ id: 'group', field: 'title' },
{ id: 'note', field: 'note' },
{ id: 'custom', field: 'Camera_Notes' },
]);
expect(outcome.total).toBe(4);
});
it('searches only the selected field', () => {
const data = [
makeEvent('title', { title: 'needle' }),
makeEvent('note', { note: 'needle' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, 'note', 'needle');
expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']);
expect(outcome.total).toBe(1);
});
it('reports the first matching field so the result can explain why it matched', () => {
const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' });
});
it('does not search image custom fields', () => {
const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })];
expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 });
});
it('reports the full match count while limiting rendered results', () => {
const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' }));
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results).toHaveLength(50);
expect(outcome.total).toBe(51);
});
});
describe('searchByIndex()', () => {
it('counts only events while preserving the flat rundown position', () => {
const data = [
makeGroup('group'),
makeDelay('delay'),
makeEvent('first'),
makeMilestone('milestone'),
makeEvent('second'),
];
const outcome = searchByIndex(data, '2');
expect(outcome.results).toHaveLength(1);
expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 });
expect(outcome.total).toBe(1);
});
it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => {
expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({
results: [],
error: 'Invalid index',
total: 0,
});
});
it('returns no result when the event index is beyond the rundown', () => {
expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 });
});
});
+228 -208
View File
@@ -1,239 +1,259 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import {
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; CustomFields,
EntryId,
MaybeNumber,
MaybeString,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { useCallback, useMemo } from 'react';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12; /** How many results we render, the total number of matches is reported separately */
const maxResults = 50;
/** Notes can hold a whole script, we only show enough to explain the match */
const excerptPadding = 40;
type FilterableGroup = { const indexFilter = 'index';
type: SupportedEntry.Group;
id: EntryId;
index: number;
title: string;
colour: string;
};
type FilterableEvent = { /** Everything except delays, which carry no text to search */
type: SupportedEntry.Event; type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
type FinderFilter = { key: string; label: string };
/**
* Offered to the user as filter badges. Index is a positional lookup rather than a
* text field, so it is handled separately from the fields a search runs over.
*/
const staticFilters: FinderFilter[] = [
{ key: indexFilter, label: 'Index' },
{ key: 'cue', label: 'Cue' },
{ key: 'title', label: 'Title' },
{ key: 'note', label: 'Note' },
];
/** Why an entry matched, so the UI can show the user */
type FinderMatch = { key: string; label: string; excerpt: string };
export type FinderResult = {
id: EntryId; id: EntryId;
/** position in the flat rundown, which is how the rundown reveals an entry */
index: number; index: number;
eventIndex: number; /** 1-based position among events, null for groups and milestones */
eventIndex: MaybeNumber;
title: string; title: string;
/** groups have no cue */
cue: string; cue: string;
colour: string; colour: string;
parent: MaybeString; parent: MaybeString;
/** absent when the entry was found by index rather than by matching text */
match: FinderMatch | null;
}; };
type FilterableMilestone = { type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
type: SupportedEntry.Milestone;
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; const noResults: SearchOutcome = { results: [], error: null, total: 0 };
export default function useFinder() { /** Groups are the only searchable entry with neither a cue nor a parent */
function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
return {
id: entry.id,
index,
eventIndex,
title: entry.title,
cue: 'cue' in entry ? entry.cue : '',
colour: entry.colour,
parent: 'parent' in entry ? entry.parent : null,
match,
} satisfies FinderResult;
}
/** Shows enough of a long value for the user to see why it matched */
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
const start = Math.max(0, matchIndex - excerptPadding);
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
}
/**
* The first field of an entry to contain the search string, if any.
* Fields are tried in the order we prefer to report a match.
*/
function findMatch(
entry: SearchableEntry,
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): FinderMatch | null {
function check(key: string, label: string, value: string): FinderMatch | null {
if (!value || (filterKey !== null && key !== filterKey)) {
return null;
}
const matchIndex = value.toLowerCase().indexOf(searchString);
if (matchIndex === -1) {
return null;
}
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
}
// groups have no cue, the rest is common to every searchable entry
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
if (match !== null) {
return match;
}
// custom fields are named by the project, so these can only be reached generically
for (const [key, value] of Object.entries(entry.custom)) {
const definition = customFields[key];
if (definition?.type !== 'text') {
continue;
}
const custom = check(key, definition.label || key, value);
if (custom) return custom;
}
return null;
}
/**
* Splits the raw search value into an optional field filter and the text to look for.
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
*/
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
for (const filter of filters) {
// the search value is already lowercased, custom field keys are not
const prefix = filter.key.toLowerCase();
if (searchValue === prefix) {
return { filterKey: filter.key, searchString: '' };
}
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
}
}
return { filterKey: null, searchString: searchValue };
}
/** Finds the single event at a 1-based position in the rundown */
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
const target = Number(indexString);
if (isNaN(target) || target < 1) {
return { ...noResults, error: 'Invalid index' };
}
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (!isOntimeEvent(entry)) {
continue;
}
eventIndex++;
if (eventIndex === target) {
return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
}
}
return noResults;
}
/**
* Matches entries on a single field when one is selected, otherwise on every text field.
* Results keep rundown order, which keeps them predictable during a show.
*/
export function searchByText(
data: OntimeEntry[],
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): SearchOutcome {
const results: FinderResult[] = [];
let total = 0;
// indexes exposed to the UI are 1-based
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (isOntimeDelay(entry)) {
continue;
}
const isEvent = isOntimeEvent(entry);
if (isEvent) {
eventIndex++;
}
const match = findMatch(entry, customFields, filterKey, searchString);
if (match === null) {
continue;
}
total++;
if (results.length < maxResults) {
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
}
}
return { results, error: null, total };
}
/**
* @param searchValue - the text the user is looking for
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data, rundownId } = useFlatRundown(); const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]); const { data: customFields } = useCustomFields();
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId); const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** Filters the rundown to a given evaluation */ /** The filters offered to the user: the fixed fields plus whatever the project defines */
const find = useCallback( const filters = useMemo<FinderFilter[]>(() => {
(event: ChangeEvent<HTMLInputElement>) => { const customFilters = Object.entries(customFields)
if (!data || data.length === 0) { .filter(([_key, field]) => field.type === 'text')
setError('No data'); .map(([key, field]) => ({ key, label: field.label || key }));
return; return [...staticFilters, ...customFilters];
} }, [customFields]);
setError(null);
if (event.target.value === '') { const { results, error, total, appliedFilter } = useMemo(() => {
setResults([]); if (data.length === 0) {
return; return { ...noResults, error: 'No data', appliedFilter: activeFilter };
} }
const searchValue = event.target.value.toLowerCase(); const normalised = searchValue.trim().toLowerCase();
lastSearchString.current = searchValue; if (normalised === '') {
return { ...noResults, appliedFilter: activeFilter };
}
if (searchValue.startsWith('index ')) { /**
const searchString = searchValue.slice('index '.length).trim(); * If a badge is selected it scopes the search; otherwise, keyword prefixes in the input
const { results, error } = searchByIndex(searchString); * (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted.
setResults(results); */
setError(error); const { filterKey, searchString } = activeFilter
return; ? { filterKey: activeFilter, searchString: normalised }
} : parseQuery(normalised, filters);
if (searchValue.startsWith('cue ')) { if (filterKey === indexFilter) {
const searchString = searchValue.slice('cue '.length).trim(); return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
const { results, error } = searchByCue(searchString); }
setResults(results); if (searchString === '') {
setError(error); // a filter is selected, but there is nothing to match on yet
return; return { ...noResults, appliedFilter: filterKey };
} }
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue; }, [data, customFields, filters, searchValue, activeFilter]);
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
/** Returns a single item with a matching index */
function searchByIndex(searchString: string) {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the cue field */
function searchByCue(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the title field*/
function searchByTitle(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const entry = data[i];
if (isOntimeEvent(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: entry.id,
index: i,
eventIndex,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableEvent);
}
eventIndex++;
} else if (isOntimeGroup(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Group,
id: entry.id,
index: i,
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 };
}
},
[data],
);
const select = useCallback( const select = useCallback(
(selectedEvent: FilterableEntry) => { (result: FinderResult) => {
selectAndRevealEntry({ selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
id: selectedEvent.id,
index: selectedEvent.index,
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
});
}, },
[selectAndRevealEntry], [selectAndRevealEntry],
); );
/** clear results when source data changes */ return { select, results, error, total, filters, appliedFilter };
useEffect(() => {
setResults([]);
setError(null);
// fake a submit event to re-run the search
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data, find]);
return { find, select, results, error };
} }
+18
View File
@@ -195,6 +195,24 @@
font-weight: 600; font-weight: 600;
} }
.sound-prompt {
position: absolute;
bottom: $view-block-padding;
left: $view-inline-padding;
padding: 0.5em 0.75em;
border-radius: $element-border-radius;
background-color: $viewer-card-bg-color;
color: $viewer-secondary-color;
font-size: $timer-label-size;
text-transform: uppercase;
pointer-events: none;
transition: opacity $viewer-transition-time;
&--hidden {
opacity: 0;
}
}
/* =================== LOGO ===================*/ /* =================== LOGO ===================*/
.logo { .logo {
position: absolute; position: absolute;
+17
View File
@@ -8,6 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock'; import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useTimerSocket } from '../../common/hooks/useSocket'; import { useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -30,6 +31,7 @@ import {
getTotalTime, getTotalTime,
} from './timer.utils'; } from './timer.utils';
import { TimerData, useTimerData } from './useTimerData'; import { TimerData, useTimerData } from './useTimerData';
import { useTimerSound } from './useTimerSound';
import './Timer.scss'; import './Timer.scss';
@@ -66,6 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
freezeOvertime, freezeOvertime,
freezeMessage, freezeMessage,
hidePhase, hidePhase,
endSound,
font, font,
keyColour, keyColour,
timerColour, timerColour,
@@ -75,6 +78,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes'); const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers // gather modifiers
const viewTimerType = timerType ?? timerTypeNow; const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer); const showOverlay = getShowMessage(message.timer);
@@ -156,6 +161,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} /> <ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
{showSoundPrompt && <SoundPermissionPrompt />}
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} /> <div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && ( {!hideMessage && (
@@ -227,3 +234,13 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
</div> </div>
); );
} }
function SoundPermissionPrompt() {
const isUserActive = useFadeOutOnInactivity(true);
return (
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
Interact with the page (click/tap or press any key) to enable sound
</div>
);
}
@@ -0,0 +1,34 @@
import { TimerPhase } from 'ontime-types';
import { shouldPlayEndSound } from '../timer.utils';
describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
'sounds when a running timer goes into overtime from %s',
(previousPhase) => {
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
},
);
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
});
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
});
it('stays silent for a roll timer waiting to start', () => {
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
});
it('sounds once, not on every update while in overtime', () => {
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
});
it('stays silent on phases which are not the end of the timer', () => {
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
});
});
+14 -1
View File
@@ -16,6 +16,7 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils'; import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean, makeColourString } from '../common/viewUtils'; import { isStringBoolean, makeColourString } from '../common/viewUtils';
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
// manually match the properties of TimerType excluding the None // manually match the properties of TimerType excluding the None
const timerDisplayOptions: SelectOption[] = [ const timerDisplayOptions: SelectOption[] = [
@@ -76,6 +77,15 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
{
id: 'endSound',
title: 'Play sound on timer end',
description:
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
type: 'option',
values: endSoundOptions,
defaultValue: 'none',
},
], ],
}, },
{ {
@@ -169,7 +179,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
{ {
id: 'timerColour', id: 'timerColour',
title: 'Timer Colour', title: 'Timer Colour',
description: 'Timer colour. Default: #f6f6f6', description: 'Timer colour. Default: #F6F6F6',
type: 'colour', type: 'colour',
defaultValue: 'f6f6f6', defaultValue: 'f6f6f6',
}, },
@@ -193,6 +203,7 @@ type TimerOptions = {
freezeOvertime: boolean; freezeOvertime: boolean;
freezeMessage: string; freezeMessage: string;
hidePhase: boolean; hidePhase: boolean;
endSound: EndSound;
font?: string; font?: string;
keyColour?: string; keyColour?: string;
timerColour?: string; timerColour?: string;
@@ -208,6 +219,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
// Get timerType from either source // Get timerType from either source
const timerType = validateTimerType(getValue('timerType'), TimerType.None); const timerType = validateTimerType(getValue('timerType'), TimerType.None);
const endSoundValue = getValue('endSound');
return { return {
hideClock: isStringBoolean(getValue('hideClock')), hideClock: isStringBoolean(getValue('hideClock')),
@@ -227,6 +239,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
freezeOvertime: isStringBoolean(getValue('freezeOvertime')), freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
freezeMessage: getValue('freezeMessage') ?? '', freezeMessage: getValue('freezeMessage') ?? '',
hidePhase: isStringBoolean(getValue('hidePhase')), hidePhase: isStringBoolean(getValue('hidePhase')),
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
font: getValue('font') ?? undefined, font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')), keyColour: makeColourString(getValue('keyColour')),
@@ -0,0 +1,20 @@
import arpeggio from '../../assets/sounds/arpeggio.wav';
import bell from '../../assets/sounds/bell.wav';
import chime from '../../assets/sounds/chime.wav';
import type { SelectOption } from '../../common/components/select/Select';
// synthesised tones bundled with the app, no external source or licence to track
export const endSoundSources = { chime, bell, arpeggio } as const;
export type EndSound = 'none' | keyof typeof endSoundSources;
export const endSoundOptions: SelectOption[] = [
{ value: 'none', label: 'None' },
{ value: 'chime', label: 'Chime' },
{ value: 'bell', label: 'Bell' },
{ value: 'arpeggio', label: 'Arpeggio' },
];
export function isEndSound(value: string | null): value is EndSound {
return value === 'none' || value === 'chime' || value === 'bell' || value === 'arpeggio';
}
@@ -189,3 +189,18 @@ export function getCardData(
nextSecondary, nextSecondary,
}; };
} }
/**
* Whether the end of timer sound should play for a given phase transition
* We only sound the transition into overtime from a phase that was already counting,
* which keeps a client that connects or reloads mid-overtime silent
*/
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
if (phase !== TimerPhase.Overtime) {
return false;
}
return (
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
);
}
@@ -0,0 +1,99 @@
import { TimerPhase } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { endSoundSources, type EndSound } from './timer.sound';
import { shouldPlayEndSound } from './timer.utils';
/**
* Plays a sound when the timer reaches its end
*
* Browsers reject playback until the document has been interacted with, and that permission
* is lost on every page load. Since a timer screen is typically left unattended, we prime the
* audio element on the first interaction and let the view prompt for one if it never comes.
* Safari grants the permission per element, so priming has to call play() on this element from
* inside the event handler, it is not enough to know that an interaction happened.
*/
export function useTimerSound(phase: TimerPhase, sound: EndSound): boolean {
const audioRef = useRef<HTMLAudioElement | null>(null);
const previousPhaseRef = useRef<TimerPhase | null>(null);
const [isArmed, setIsArmed] = useState(false);
const enabled = sound !== 'none';
// Create and clean up the audio element; changing sounds requires re-arming it in Safari.
useEffect(() => {
setIsArmed(false);
if (sound === 'none') {
return;
}
const audio = new Audio(endSoundSources[sound]);
audioRef.current = audio;
return () => {
audio.pause();
audioRef.current = null;
};
}, [sound]);
// Listen for user interaction until muted playback succeeds and arms the selected audio element.
useEffect(() => {
if (!enabled || isArmed) {
return;
}
const controller = new AbortController();
const prime = () => {
const audio = audioRef.current;
if (!audio) {
return;
}
const wasMuted = audio.muted;
audio.muted = true;
audio
.play()
.then(() => {
if (audioRef.current !== audio) {
return;
}
audio.pause();
audio.currentTime = 0;
setIsArmed(true);
})
.catch(() => {
// playback is still blocked, a later interaction will try again
})
.finally(() => {
audio.muted = wasMuted;
});
};
document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal });
document.addEventListener('keydown', prime, { capture: true, signal: controller.signal });
return () => {
controller.abort();
};
}, [enabled, isArmed]);
// Track phase transitions and play only when a running timer enters overtime.
useEffect(() => {
const previousPhase = previousPhaseRef.current;
previousPhaseRef.current = phase;
if (!enabled || !shouldPlayEndSound(previousPhase, phase)) {
return;
}
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
audio.play().catch(() => {
// the screen has not been interacted with, the view shows a prompt for it
});
}, [enabled, phase]);
return enabled && !isArmed;
}
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.12.0", "version": "4.13.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -14,8 +14,8 @@
"main": "src/main.js", "main": "src/main.js",
"devDependencies": { "devDependencies": {
"electron": "38.2.1", "electron": "38.2.1",
"electron-builder": "26.9.1", "electron-builder": "26.15.3",
"wait-on": "^7.2.0" "wait-on": "^9.0.0"
}, },
"scripts": { "scripts": {
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .", "dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
+13 -1
View File
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
submenu: [ submenu: [
{ {
label: 'New project...', label: 'New project...',
click: () => redirectWindow('/editor?settings=project__manage&new=true'), click: () => redirectWindow('/editor?settings=project__create'),
}, },
{ {
label: 'Load...', label: 'Load...',
@@ -202,6 +202,18 @@ function makeSettingsMenu(redirectWindow) {
label: 'View settings', label: 'View settings',
click: () => redirectWindow('/editor?settings=settings__view'), click: () => redirectWindow('/editor?settings=settings__view'),
}, },
{
label: 'Custom views',
click: () => redirectWindow('/editor?settings=settings__custom-views'),
},
{
label: 'MCP Server',
click: () => redirectWindow('/editor?settings=settings__mcp'),
},
{
label: 'Server port',
click: () => redirectWindow('/editor?settings=settings__port'),
},
], ],
}, },
{ {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/resolver", "name": "@getontime/resolver",
"version": "4.12.0", "version": "4.13.0",
"type": "module", "type": "module",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
@@ -17,7 +17,7 @@
"parser" "parser"
], ],
"author": "", "author": "",
"license": "AGPL-3.0-only", "license": "ISC",
"devDependencies": { "devDependencies": {
"ontime-types": "workspace:^4.2.1", "ontime-types": "workspace:^4.2.1",
"tsdown": "^0.22.4", "tsdown": "^0.22.4",
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.12.0", "version": "4.13.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -10,7 +10,7 @@
"cookie": "1.0.2", "cookie": "1.0.2",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cors": "2.8.6", "cors": "2.8.6",
"dotenv": "^16.0.1", "dotenv": "^17.0.0",
"express": "5.2.1", "express": "5.2.1",
"express-static-gzip": "3.0.1", "express-static-gzip": "3.0.1",
"express-validator": "7.3.2", "express-validator": "7.3.2",
@@ -31,11 +31,11 @@
"@types/multer": "2.1.0", "@types/multer": "2.1.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"esbuild": "^0.24.0", "esbuild": "^0.28.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"server-timing": "^3.3.3", "server-timing": "^3.3.3",
"ts-essentials": "catalog:", "ts-essentials": "catalog:",
"tsx": "^4.19.2", "tsx": "^4.23.12",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
}, },
@@ -1,5 +1,7 @@
import { deepEqual } from 'fast-equals';
import { import {
EndAction, EndAction,
OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeGroup, OntimeGroup,
OntimeMilestone, OntimeMilestone,
@@ -8,13 +10,21 @@ import {
TimerType, TimerType,
Trigger, Trigger,
} from 'ontime-types'; } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils'; import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
createDelay,
createEvent,
createGroup,
createMilestone,
} from 'ontime-utils';
import { assertType } from 'vitest'; import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js'; import { parseRundown } from '../rundown.parser.js';
import { import {
calculateDayOffset, calculateDayOffset,
cloneEntryData,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
getIntegerAndFraction, getIntegerAndFraction,
@@ -715,3 +725,68 @@ describe('eventDurationMatchGroupTarget()', () => {
expect(result).toStrictEqual(null); expect(result).toStrictEqual(null);
}); });
}); });
describe('cloneEntryData()', () => {
const trigger: Trigger = {
id: 'trigger-1',
title: 'Go on air',
trigger: TimerLifeCycle.onStart,
automationId: 'automation-1',
};
// the real factories, so these are complete entries exactly as the rundown holds them
const entries: [string, OntimeEntry][] = [
['event', createEvent({ custom: { sponsor: 'a value' }, triggers: [trigger] }, 'cue-1') as OntimeEvent],
['group', createGroup({ id: 'group-1', entries: ['a', 'b'], custom: { sponsor: 'a value' } })],
['milestone', createMilestone({ id: 'milestone-1', custom: { sponsor: 'a value' } })],
['delay', createDelay({ id: 'delay-1', duration: 10 })],
];
/**
* Fails if any nested object or array in the clone is the same reference as the source,
* so a field added later that needs a copy of its own is caught here without the clone
* having to enumerate fields.
*/
function expectNoSharedReferences(clone: unknown, source: unknown, path: string) {
if (typeof source !== 'object' || source === null) return;
expect(clone, `${path} is shared with the source`).not.toBe(source);
const cloneRecord = clone as Record<string, unknown>;
const sourceRecord = source as Record<string, unknown>;
for (const key of Object.keys(sourceRecord)) {
expectNoSharedReferences(cloneRecord[key], sourceRecord[key], `${path}.${key}`);
}
}
/** the two halves of the structuredClone contract: same value, no shared references */
it.each(entries)('clones a %s to the same value structuredClone would produce', (_type, entry) => {
expect(cloneEntryData(entry)).toStrictEqual(structuredClone(entry));
});
it.each(entries)('shares no nested object or array with the source %s', (_type, entry) => {
expectNoSharedReferences(cloneEntryData(entry), entry, 'entry');
});
/**
* Regression: normalising an absent container to an empty one makes deepEqual report a
* change on every comparison, which would have the runtime re-broadcast and re-save the
* restore point on every tick. See PR #2178.
*/
it.each([
['event', makeOntimeEvent({ id: 'partial' })],
['group', makeOntimeGroup({ id: 'partial', entries: undefined })],
])('gives a partial %s exactly the keys structuredClone would, so it stays deep-equal', (_type, entry) => {
const clone = cloneEntryData(entry);
// asserting on keys, not values: `toBeUndefined()` cannot tell an absent key from an own
// key holding undefined, and it is key presence that decides the deepEqual below
expect(Object.keys(clone).sort()).toEqual(Object.keys(structuredClone(entry)).sort());
// this is the comparison runtime.service.ts uses to decide whether to re-broadcast an
// entry; if the clone gains a key, every tick looks like a change
expect(deepEqual(clone, entry)).toBe(true);
});
it('throws on an entry type it does not know how to clone', () => {
expect(() => cloneEntryData({ id: 'x', type: 'unknown' } as unknown as OntimeEvent)).toThrow(
'Unsupported entry type for cloning',
);
});
});
+38 -14
View File
@@ -27,6 +27,7 @@ import {
isPlayableEvent, isPlayableEvent,
} from 'ontime-types'; } from 'ontime-types';
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils'; import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
import type { DeepReadonly } from 'ts-essentials';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js'; import { consoleError } from '../../utils/console.js';
@@ -34,6 +35,7 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser.
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { import {
applyPatchToEntry, applyPatchToEntry,
cloneRundown,
cloneSimpleRundownEntry, cloneSimpleRundownEntry,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
@@ -43,9 +45,14 @@ import {
} from './rundown.utils.js'; } from './rundown.utils.js';
/** /**
* The currently loaded rundown in cache * The currently loaded rundown in cache.
*
* Reassigned - never mutated in place - when a different rundown is loaded: the persistence
* layer stores this object by reference, so repurposing it for another rundown would rewrite
* the previously loaded rundown's stored record. Mutating it in place while it represents the
* same rundown (ie. from commit) is intended, and is what keeps the stored record current.
*/ */
const cachedRundown: Rundown = { let cachedRundown: Rundown = {
id: '', id: '',
title: '', title: '',
order: [], order: [],
@@ -79,9 +86,16 @@ export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetada
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields; export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId]; export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
type Transaction = { /**
* @param R the type callers see for `rundown` - a plain, mutable `Rundown` when the
* transaction was opened with `mutableRundown: true`, otherwise a `DeepReadonly<Rundown>`
* so that accidentally mutating an entry (or an order array) on a non-mutable transaction
* - which would silently corrupt the live cache without going through commit() - is a
* compile-time error instead of a runtime bug.
*/
type Transaction<R> = {
customFields: CustomFields; customFields: CustomFields;
rundown: Rundown; rundown: R;
commit: (shouldProcess?: boolean) => Promise<{ commit: (shouldProcess?: boolean) => Promise<{
rundown: Readonly<Rundown>; rundown: Readonly<Rundown>;
@@ -102,11 +116,17 @@ type TransactionOptions = {
rundownId?: string; rundownId?: string;
}; };
export function createTransaction(options: TransactionOptions): Transaction { export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction<Rundown>;
export function createTransaction(
options: TransactionOptions & { mutableRundown?: false },
): Transaction<DeepReadonly<Rundown>>;
export function createTransaction(
options: TransactionOptions,
): Transaction<Rundown> | Transaction<DeepReadonly<Rundown>> {
const targetId = options.rundownId ?? cachedRundown.id; const targetId = options.rundownId ?? cachedRundown.id;
const isLoaded = targetId === cachedRundown.id; const isLoaded = targetId === cachedRundown.id;
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown); const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown; const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown;
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields; const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
/** /**
@@ -707,21 +727,25 @@ export const customFieldMutation = {
* Expose function to add an initial rundown to the system * Expose function to add an initial rundown to the system
*/ */
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) { export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
const rundown = structuredClone(initialRundown); const rundown = cloneRundown(initialRundown);
const customFields = structuredClone(initialCustomFields); const customFields = structuredClone(initialCustomFields);
const processedData = processRundown(rundown, customFields, { mutate: true }); const processedData = processRundown(rundown, customFields, { mutate: true });
// update the cache values
cachedRundown.id = rundown.id;
cachedRundown.title = rundown.title;
projectCustomFields = customFields; projectCustomFields = customFields;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData; const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order; // a fresh object, so that the record already stored for a previously loaded rundown keeps
cachedRundown.flatOrder = metadata.flatEntryOrder; // pointing at that rundown's data - see the note on cachedRundown
cachedRundown.revision = rundown.revision; cachedRundown = {
id: rundown.id,
title: rundown.title,
entries,
order,
flatOrder: metadata.flatEntryOrder,
revision: rundown.revision,
};
rundownMetadata = metadata; rundownMetadata = metadata;
// defer writing to the database // defer writing to the database
@@ -33,7 +33,7 @@ import {
import { makeNewRundown } from '../../models/dataModel.js'; import { makeNewRundown } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js'; import { RundownMetadata } from './rundown.types.js';
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js'; import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js';
/** /**
* Parse a rundowns object along with the project custom fields * Parse a rundowns object along with the project custom fields
@@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta
}; };
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T { function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup); return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup);
} }
function getMetadata(): ProcessedRundownMetadata { function getMetadata(): ProcessedRundownMetadata {
@@ -39,6 +39,7 @@ import {
import { parseRundown } from './rundown.parser.js'; import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { import {
cloneRundown,
generateEvent, generateEvent,
getFirstInsertId, getFirstInsertId,
getIntegerAndFraction, getIntegerAndFraction,
@@ -626,7 +627,7 @@ export async function editCustomField(
// ... reassign references in the background rundowns // ... reassign references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) { for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) { if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]); const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey); customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
await updateBackgroundRundown(rundownId, backgroundRundown); await updateBackgroundRundown(rundownId, backgroundRundown);
} }
@@ -666,7 +667,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
// remove references in the background rundowns // remove references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) { for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) { if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]); const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key); customFieldMutation.removeUsages(backgroundRundown, key);
await updateBackgroundRundown(rundownId, backgroundRundown); await updateBackgroundRundown(rundownId, backgroundRundown);
} }
@@ -818,7 +819,7 @@ export async function renameRundown(id: string, title: string) {
const dataProvider = getDataProvider(); const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id); const rundown = dataProvider.getRundown(id);
await dataProvider.setRundown(id, { ...rundown, title }); await dataProvider.setRundown(id, { ...rundown, title, revision: rundown.revision + 1 });
/** /**
* If we are modifying the loaded rundown we re-init it * If we are modifying the loaded rundown we re-init it
@@ -846,7 +847,7 @@ export async function duplicateExistingRundown(id: string) {
const dataProvider = getDataProvider(); const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id); const rundown = dataProvider.getRundown(id);
const duplicatedRundown: Rundown = structuredClone(rundown); const duplicatedRundown: Rundown = cloneRundown(rundown);
duplicatedRundown.id = generateId(); duplicatedRundown.id = generateId();
duplicatedRundown.title = `Copy of ${rundown.title}`; duplicatedRundown.title = `Copy of ${rundown.title}`;
duplicatedRundown.revision = 0; duplicatedRundown.revision = 0;
@@ -329,7 +329,7 @@ export function mergeRundownPreservingFields(
const structure = isOntimeGroup(incomingEntry) const structure = isOntimeGroup(incomingEntry)
? { entries: incomingEntry.entries } ? { entries: incomingEntry.entries }
: { parent: incomingEntry.parent }; : { parent: incomingEntry.parent };
entries[id] = structuredClone({ ...merged, ...structure }); entries[id] = cloneEntryData({ ...merged, ...structure });
} }
return { return {
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
/** List of event properties which do not need the rundown to be regenerated */ /** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist { enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID id, // adding it for completeness, users cannot change ID
'type', // adding it for completeness, users cannot change ID type, // adding it for completeness, users cannot change ID
'cue', cue,
'title', title,
'note', note,
'endAction', endAction,
'timerType', timerType,
'countToEnd', countToEnd,
'colour', colour,
'timeWarning', timeWarning,
'timeDanger', timeDanger,
'custom', custom,
'triggers', triggers,
} }
/** /**
@@ -499,6 +499,61 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
throw new Error(`Unsupported entry type for cloning: ${entry}`); throw new Error(`Unsupported entry type for cloning: ${entry}`);
} }
/**
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
* Drop-in replacement for `structuredClone(entry)`
*/
export function cloneEntryData<T extends OntimeEntry>(entry: T): T {
switch (entry.type) {
case SupportedEntry.Event: {
const clone: OntimeEvent = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.triggers) clone.triggers = clone.triggers.map((trigger) => ({ ...trigger }));
return clone as T;
}
case SupportedEntry.Group: {
const clone: OntimeGroup = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.entries) clone.entries = clone.entries.slice();
return clone as T;
}
case SupportedEntry.Milestone: {
const clone: OntimeMilestone = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
return clone as T;
}
case SupportedEntry.Delay:
return { ...entry } as T;
default: {
// exhaustiveness guard: a new member of `SupportedEntry` is named in the error here
const unhandled: never = entry;
throw new Error(`Unsupported entry type for cloning: ${(unhandled as OntimeEntry).type}`);
}
}
}
/**
* Fast, shape-aware clone of a whole rundown.
* Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested
* `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate
* the result freely without touching the source - same contract as structuredClone,
* at a fraction of the cost since we skip the generic serialization algorithm.
*/
export function cloneRundown(rundown: Readonly<Rundown>): Rundown {
const entries: RundownEntries = {};
for (const id in rundown.entries) {
entries[id] = cloneEntryData(rundown.entries[id]);
}
return {
id: rundown.id,
title: rundown.title,
revision: rundown.revision,
order: rundown.order.slice(),
flatOrder: rundown.flatOrder.slice(),
entries,
};
}
/** /**
* Utility for calculating if the current events should have a day offset * Utility for calculating if the current events should have a day offset
* @param current the current event under test * @param current the current event under test
@@ -464,9 +464,8 @@ export async function upload(sheetId: string, options: ImportMap) {
sheetOrder.forEach((entryId, index) => { sheetOrder.forEach((entryId, index) => {
const isGroupEnd = entryId.startsWith('group-end-'); const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId; const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
const entry = isGroupEnd // cellRequestFromEvent only reads the entry to build a cell request, no clone is needed
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id];
: structuredClone(rundown.entries[id]);
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
}); });
} catch (e) { } catch (e) {
-21
View File
@@ -1,21 +0,0 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
/**
* Wraps the app authenticate middleware with support for the Authorization header.
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
* rather than cookies or query params; any other request falls through to the
* app middleware, keeping the behaviour of the shared middleware untouched.
*/
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
if (hasPassword) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
return next();
}
}
return fallback(req, res, next);
};
}
+3 -6
View File
@@ -20,12 +20,9 @@ export function createMcpServer(): Server {
{ capabilities: { tools: {}, prompts: {}, resources: {} } }, { capabilities: { tools: {}, prompts: {}, resources: {} } },
); );
server.setRequestHandler( server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
ListToolsRequestSchema, tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
async (): Promise<ListToolsResult> => ({ }));
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => { server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params; const { name, arguments: args = {} } = request.params;
+1 -2
View File
@@ -13,7 +13,6 @@ import { socket } from './adapters/WebsocketAdapter.js';
// Import Routers // Import Routers
import { appRouter } from './api-data/index.js'; import { appRouter } from './api-data/index.js';
import { integrationRouter } from './api-integration/integration.router.js'; import { integrationRouter } from './api-integration/integration.router.js';
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
import { mcpRouter } from './api-mcp/mcp.router.js'; import { mcpRouter } from './api-mcp/mcp.router.js';
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js'; import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
// Services // Services
@@ -102,7 +101,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
app.use(`${prefix}/login`, loginRouter); // router for login flow app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
// serve static external files // serve static external files
app.use( app.use(
@@ -101,8 +101,16 @@ function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields; return db.data.customFields;
} }
/**
* Stores a rundown, replacing any existing entry for the same key.
* Takes ownership of `newData` and stores it by reference - the caller must not mutate it
* afterward. Every call site either hands over a freshly-built object it never touches again,
* or (for the loaded rundown) the cache's own long-lived object, which is already the single
* source of truth for that data - aliasing it here costs nothing and avoids a second full
* deep copy of the rundown on every commit.
*/
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> { async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
db.data.rundowns[rundownKey] = structuredClone(newData); db.data.rundowns[rundownKey] = newData;
await persist(); await persist();
return db.data.rundowns; return db.data.rundowns;
} }
@@ -4,12 +4,17 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project * Merges a partial ontime project into a given ontime project
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const deepExisting = structuredClone(existing); // rundowns are merged separately below by reference (only the top-level map is copied,
const deepNewData = structuredClone(newData); // same as the other properties here) - deep-cloning them here would be wasted work,
// since a project's rundowns are by far the largest part of this object
const { rundowns: existingRundowns, ...existingRest } = existing;
const { rundowns: newRundowns = {}, ...newDataRest } = newData;
const deepExisting = structuredClone(existingRest);
const deepNewData = structuredClone(newDataRest);
// destructure each property to simplify merging not provided ie: ...{} has no effect // destructure each property to simplify merging not provided ie: ...{} has no effect
const { const {
rundowns = {},
project = {}, project = {},
settings = {}, settings = {},
viewSettings = {}, viewSettings = {},
@@ -19,7 +24,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
} = deepNewData; } = deepNewData;
return { return {
rundowns: { ...existing.rundowns, ...rundowns }, rundowns: { ...existingRundowns, ...newRundowns },
project: { ...deepExisting.project, ...project }, project: { ...deepExisting.project, ...project },
settings: { ...deepExisting.settings, ...settings }, settings: { ...deepExisting.settings, ...settings },
viewSettings: { ...deepExisting.viewSettings, ...viewSettings }, viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
@@ -1,6 +1,31 @@
import { describe, expect, it } from 'vitest'; import type { IncomingMessage } from 'node:http';
import { isPublicAssetRequest } from '../authenticate.js'; import type { NextFunction, Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/session/session.service.js', () => ({
hasPassword: true,
hashedPassword: 'valid-token',
}));
import { authenticateSocket, isPublicAssetRequest, makeAuthenticateMiddleware } from '../authenticate.js';
function makeResponse() {
return {
redirect: vi.fn(),
send: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response;
}
function makeHeadersWithFailingAuthorization(cookie?: string) {
return {
cookie,
get authorization(): never {
throw new Error('Authorization header should not be read');
},
};
}
describe('isPublicAssetRequest()', () => { describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => { it('allows root public assets without a prefix', () => {
@@ -18,3 +43,144 @@ describe('isPublicAssetRequest()', () => {
expect(isPublicAssetRequest('/backstage', '')).toBe(false); expect(isPublicAssetRequest('/backstage', '')).toBe(false);
}); });
}); });
describe('bearer authentication', () => {
const next = vi.fn() as NextFunction;
beforeEach(() => {
next.mockClear();
});
it('prioritises cookie authentication for API requests', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
query: {},
} as unknown as Request;
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for redirecting routes', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
originalUrl: '/external/image.png',
query: {},
} as unknown as Request;
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for WebSocket handshakes', () => {
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('authenticates API requests with a bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
it('accepts case-insensitive bearer schemes and extra whitespace', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'bearer valid-token ' },
query: {},
} as unknown as Request;
authenticate(req, makeResponse(), next);
expect(next).toHaveBeenCalledOnce();
});
it('authenticates redirecting routes with a bearer token', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
originalUrl: '/stage/external/image.png',
query: {},
} as unknown as Request;
const res = makeResponse();
authenticateAndRedirect(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.redirect).not.toHaveBeenCalled();
});
it('authenticates WebSocket handshakes with a bearer token', () => {
const req = {
headers: { authorization: 'Bearer valid-token' },
} as IncomingMessage;
authenticateSocket({} as never, req, next);
expect(next).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith();
});
it('rejects an invalid bearer token', () => {
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer invalid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
const redirectReq = { ...req, originalUrl: '/external/image.png' } as Request;
const redirectRes = makeResponse();
authenticateAndRedirect(redirectReq, redirectRes, next);
expect(next).not.toHaveBeenCalled();
expect(redirectRes.redirect).toHaveBeenCalledWith('/login?redirect=/external/image.png');
const socketNext = vi.fn();
authenticateSocket(
{} as never,
{ headers: { authorization: 'Bearer invalid-token' } } as IncomingMessage,
socketNext,
);
expect(socketNext).toHaveBeenCalledOnce();
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
});
it.each(['/socket?not_token=valid-token', '/socket?token=valid-token-suffix'])(
'rejects lookalike WebSocket query tokens in %s',
(url) => {
const socketNext = vi.fn();
authenticateSocket({} as never, { headers: { host: 'localhost' }, url } as IncomingMessage, socketNext);
expect(socketNext).toHaveBeenCalledOnce();
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
},
);
});
+46 -49
View File
@@ -77,17 +77,16 @@ export function makeAuthenticateMiddleware(prefix: string) {
const loginRedirectBase = `${prefix}/login?redirect=`; const loginRedirectBase = `${prefix}/login?redirect=`;
function authenticate(req: Request, res: Response, next: NextFunction) { function authenticate(req: Request, res: Response, next: NextFunction) {
if (req.query.token) { if (getTokenFromCookies(req.cookies) === hashedPassword) {
if (req.query.token === hashedPassword) { return next();
return next();
}
} }
if (req.cookies?.token) { if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token); return next();
if (tokenFromCookie === hashedPassword) { }
return next();
} if (getTokenFromParams(req.query) === hashedPassword) {
return next();
} }
res.status(401).send('Unauthorized'); res.status(401).send('Unauthorized');
@@ -105,17 +104,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
return next(); return next();
} }
// we expect the token to be in the cookies if (getTokenFromCookies(req.cookies) === hashedPassword) {
if (req.cookies?.token) { return next();
const tokenFromCookie = getTokenFromCookie(req.cookies.token); }
if (tokenFromCookie === hashedPassword) {
return next(); if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
} return next();
} }
// we use query params for generating authenticated URLs and for clients like the companion module // we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests // if the user gives is a token in the query params, we set the cookie to be used in further requests
if (req.query.token === hashedPassword) { if (getTokenFromParams(req.query) === hashedPassword) {
if (hashedPassword !== undefined) { if (hashedPassword !== undefined) {
setSessionCookie(res, hashedPassword, prefix); setSessionCookie(res, hashedPassword, prefix);
} }
@@ -136,33 +135,16 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
return next(); return next();
} }
// check if the token is in the cookie if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token) {
const token = getTokenFromCookie(cookies.token);
if (token === hashedPassword) {
return next();
}
}
}
// check if token is in the params - simple string check first
const urlString = req.url || '';
if (urlString.includes(`token=${hashedPassword}`)) {
return next(); return next();
} }
// fallback to full URL parsing for other formats if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
try { return next();
const url = new URL(urlString, `http://${req.headers.host}`); }
const token = url.searchParams.get('token');
if (token === hashedPassword) { if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
return next(); return next();
}
} catch (_) {
// ignore URL parsing errors
} }
return next(new Error('Unauthorized')); return next(new Error('Unauthorized'));
@@ -181,19 +163,18 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
}); });
} }
/** function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
* When calling this function we already know a cookie called 'token' exists const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
* And want to extract its value if (typeof cookieContents !== 'string') {
*/ return undefined;
function getTokenFromCookie(cookieContents: string): string | undefined { }
// Fast path: check if the hashed password is directly in the cookie string
// This avoids JSON parsing for the common case // Fast path: avoid JSON parsing when the expected token can be found directly
const cookieTokenString = '"token":"' + hashedPassword + '}"'; const cookieTokenString = '"token":"' + hashedPassword + '"';
if (cookieTokenString && cookieContents.includes(cookieTokenString)) { if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
return hashedPassword; return hashedPassword;
} }
// Fallback to JSON parsing for other cases or validation
try { try {
const cookie = JSON.parse(cookieContents); const cookie = JSON.parse(cookieContents);
if (cookie && typeof cookie.token === 'string') { if (cookie && typeof cookie.token === 'string') {
@@ -203,3 +184,19 @@ function getTokenFromCookie(cookieContents: string): string | undefined {
// no error handling to do here // no error handling to do here
} }
} }
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
return authorization?.match(/^Bearer\s+(\S+)\s*$/i)?.[1];
}
function getTokenFromParams(params: string | Record<string, unknown> | undefined, host?: string): string | undefined {
if (typeof params !== 'string') {
return typeof params?.token === 'string' ? params.token : undefined;
}
try {
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
} catch (_) {
return undefined;
}
}
@@ -1,7 +1,6 @@
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import * as timeCore from '../../lib/time-core/timeCore.js';
import type { RuntimeState } from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
@@ -54,12 +53,11 @@ describe('getElapsed()', () => {
it('uses the current pause start while paused', () => { it('uses the current pause start while paused', () => {
const state = { const state = {
clock: 10 * MILLIS_PER_MINUTE, clock: 10 * MILLIS_PER_MINUTE,
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: { timer: {
startedAt: 2 * MILLIS_PER_MINUTE, startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()), pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE, pausedDuration: 1 * MILLIS_PER_MINUTE,
}, },
} as RuntimeState; } as RuntimeState;
@@ -977,40 +975,6 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(25); expect(absolute).toBe(25);
}); });
it('paused time is delayed time when the pause spans midnight', () => {
const state = {
eventNow: {
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
dayOffset: 0,
},
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: {
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
current: 25, // still counting down
addedTime: 0,
},
_timer: {
pausedAt: timeCore.toInstant(
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
(timeCore.now() - dayInMs) as Instant,
), // 23:58, before midnight
pausedDuration: 0,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedStart: 23 * MILLIS_PER_HOUR,
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
});
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
const state = { const state = {
clock: 78480789, clock: 78480789,
@@ -0,0 +1,226 @@
import { Offset, OffsetMode, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
import {
findNextPlayableId,
findNextPlayableWithCue,
findPreviousPlayableId,
getEventAtIndex,
getShouldClockUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
isNewSecond,
} from '../runtime.utils.js';
describe('isNewSecond()', () => {
it('is false while the value moves within the same second', () => {
// count down rounds up, so both resolve to second 2
expect(isNewSecond(1500, 1200)).toBe(false);
});
it('is true once the value crosses a second boundary', () => {
expect(isNewSecond(1001, 1000)).toBe(true);
});
it('rounds according to the given direction', () => {
// 1200 -> ceil 2 / floor 1, 1800 -> ceil 2 / floor 1
expect(isNewSecond(1200, 1800, TimerType.CountDown)).toBe(false);
expect(isNewSecond(1200, 1800, TimerType.CountUp)).toBe(false);
// 1200 -> ceil 2 / floor 1, 2200 -> ceil 3 / floor 2
expect(isNewSecond(1200, 2200, TimerType.CountDown)).toBe(true);
expect(isNewSecond(1200, 2200, TimerType.CountUp)).toBe(true);
});
it('treats null and undefined as second zero', () => {
expect(isNewSecond(undefined, null)).toBe(false);
expect(isNewSecond(null, 0)).toBe(false);
expect(isNewSecond(undefined, 500)).toBe(true);
});
});
describe('getShouldClockUpdate()', () => {
it('is false within the same second and true across the boundary', () => {
expect(getShouldClockUpdate(1000, 1999)).toBe(false);
expect(getShouldClockUpdate(1000, 2000)).toBe(true);
});
});
describe('getShouldTimerUpdate()', () => {
const baseTimer: TimerState = {
addedTime: 0,
current: 10000,
duration: 10000,
elapsed: 0,
expectedFinish: 10000,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 0,
};
it('always updates when there is no previous state', () => {
expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true);
});
it('does not update while the timer ticks within the same second', () => {
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 9500 })).toBe(false);
});
it('updates when the timer crosses a second', () => {
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 8999 })).toBe(true);
});
it('updates when the secondary timer crosses a second', () => {
const previous = { ...baseTimer, secondaryTimer: 2000 };
// counting down rounds up, so 1999 is still second 2
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1999 })).toBe(false);
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1000 })).toBe(true);
});
it.each([
['addedTime', { addedTime: 1 }],
['duration', { duration: 1 }],
['phase', { phase: TimerPhase.Warning }],
['playback', { playback: Playback.Pause }],
['startedAt', { startedAt: 1 }],
])('updates immediately when %s changes', (_label, patch) => {
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(true);
});
it.each([
['elapsed', { elapsed: 1 }],
['expectedFinish', { expectedFinish: 1 }],
])('does not update on %s alone, since it is derived', (_label, patch) => {
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(false);
});
});
describe('getShouldOffsetUpdate()', () => {
const baseOffset: Offset = {
absolute: 0,
relative: 0,
mode: OffsetMode.Absolute,
expectedGroupEnd: null,
expectedRundownEnd: null,
expectedFlagStart: null,
};
it('always updates when there is no previous state', () => {
expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true);
});
it('updates on a mode change even when no dependency ticked', () => {
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset, mode: OffsetMode.Relative }, false)).toBe(true);
});
it('holds back value changes until a dependency ticks', () => {
const next = { ...baseOffset, absolute: 1000 };
expect(getShouldOffsetUpdate(baseOffset, next, false)).toBe(false);
expect(getShouldOffsetUpdate(baseOffset, next, true)).toBe(true);
});
it('does not update when a dependency ticked but nothing changed', () => {
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false);
});
});
describe('findPreviousPlayableId()', () => {
const order = ['1', '2', '3'];
it('returns undefined when there is nothing to play', () => {
expect(findPreviousPlayableId([])).toBeUndefined();
});
it('returns the first event when nothing is loaded', () => {
expect(findPreviousPlayableId(order)).toBe('1');
});
it('returns the preceding event', () => {
expect(findPreviousPlayableId(order, '3')).toBe('2');
});
it('stays on the first event when already at the top', () => {
expect(findPreviousPlayableId(order, '1')).toBe('1');
});
it('falls back to the first event when the loaded id is unknown', () => {
expect(findPreviousPlayableId(order, 'not-in-rundown')).toBe('1');
});
});
describe('findNextPlayableId()', () => {
const order = ['1', '2', '3'];
it('returns undefined when there is nothing to play', () => {
expect(findNextPlayableId([])).toBeUndefined();
});
it('returns the first event when nothing is loaded', () => {
expect(findNextPlayableId(order)).toBe('1');
});
it('returns the following event', () => {
expect(findNextPlayableId(order, '1')).toBe('2');
});
it('wraps to the first event from the last', () => {
expect(findNextPlayableId(order, '3')).toBe('1');
});
it('falls back to the first event when the loaded id is unknown', () => {
expect(findNextPlayableId(order, 'not-in-rundown')).toBe('1');
});
});
describe('findNextPlayableWithCue()', () => {
const rundown = makeRundown({
order: ['1', '2', '3', '4'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'a' }),
'2': makeOntimeEvent({ id: '2', cue: 'b' }),
'3': makeOntimeEvent({ id: '3', cue: 'b', skip: true }),
'4': makeOntimeEvent({ id: '4', cue: 'b' }),
},
});
const order = ['1', '2', '3', '4'];
it('finds the next event with the given cue', () => {
expect(findNextPlayableWithCue(rundown, order, 'b')?.id).toBe('2');
});
it('skips events which are not playable', () => {
expect(findNextPlayableWithCue(rundown, order, 'b', 2)?.id).toBe('4');
});
it('wraps around to the start of the rundown', () => {
expect(findNextPlayableWithCue(rundown, order, 'a', 2)?.id).toBe('1');
});
it('excludes the current event unless allowCurrent is set', () => {
expect(findNextPlayableWithCue(rundown, order, 'b', 1)?.id).toBe('4');
expect(findNextPlayableWithCue(rundown, order, 'b', 1, true)?.id).toBe('2');
});
it('returns undefined when no event carries the cue', () => {
expect(findNextPlayableWithCue(rundown, order, 'missing')).toBeUndefined();
});
});
describe('getEventAtIndex()', () => {
const rundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1' }),
'2': makeOntimeEvent({ id: '2' }),
},
});
it('returns the event at the given index', () => {
expect(getEventAtIndex(rundown, ['1', '2'], 1)?.id).toBe('2');
});
it('returns undefined when the index is out of range', () => {
expect(getEventAtIndex(rundown, ['1', '2'], 5)).toBeUndefined();
expect(getEventAtIndex(rundown, [], 0)).toBeUndefined();
});
});
@@ -20,6 +20,7 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
import { triggerReportEntry } from '../../api-data/report/report.service.js'; import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js'; import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js'; import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
@@ -754,7 +755,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
} }
// at this point we know that either the id or the contents has changed // at this point we know that either the id or the contents has changed
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
RuntimeService.previousState[key] = structuredClone(currentEntry); RuntimeService.previousState[key] = currentEntry ? cloneEntryData(currentEntry) : null;
return true; return true;
} }
+5 -7
View File
@@ -1,7 +1,6 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types'; import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js';
import type { RuntimeState } from '../stores/runtimeState.js'; import type { RuntimeState } from '../stores/runtimeState.js';
/** /**
@@ -97,18 +96,17 @@ export function getCurrent(state: RuntimeState): number {
* Calculates active time elapsed since the timer started. * Calculates active time elapsed since the timer started.
*/ */
export function getElapsed(state: RuntimeState): MaybeNumber { export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock, _now } = state; const { clock } = state;
const { startedAt } = state.timer; const { startedAt } = state.timer;
const { pausedDuration, pausedAt } = state._timer; const { pausedAt, pausedDuration } = state._timer;
if (startedAt === null) { if (startedAt === null) {
return null; return null;
} }
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0; const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const elapsedSinceStart = getTimeSinceStart(clock, startedAt); const activeElapsed = elapsedSinceStart - pausedDuration;
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
return Math.max(0, activeElapsed); return Math.max(0, activeElapsed);
} }
@@ -1,11 +1,10 @@
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types'; import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils'; import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js'; import type { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = { const baseState: RuntimeState = {
clock: 0 as TimeOfDay, clock: 0 as TimeOfDay,
_now: 0 as Instant,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
eventFlag: null, eventFlag: null,
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
playback: Playback.Pause, playback: Playback.Pause,
addedTime: 0, addedTime: 0,
}); });
expect(newState._timer.pausedAt).toEqual(newState._now); expect(newState._timer.pausedAt).toEqual(newState.clock);
success = pause(); success = pause();
expect(success).toBe(false); expect(success).toBe(false);
@@ -248,59 +248,6 @@ describe('mutation on runtimeState', () => {
state = getState(); state = getState();
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE); expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
}); });
test('elapsed excludes a pause that spans midnight', async () => {
clearState();
// an event that runs over midnight (23:00 -> 01:00)
const event = {
...mockEvent,
id: 'elapsed-pause-midnight',
timeStart: 23 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
};
const mockRundown = makeRundown({
entries: { [event.id]: event },
order: [event.id],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start before midnight
vi.setSystemTime('jan 1 23:50');
load(event, rundown, metadata);
start();
// 8 minutes of active running before we pause
vi.setSystemTime('jan 1 23:58');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
pause();
// elapsed is active time since start, so it must not advance while paused,
// not even when the pause itself crosses midnight
vi.setSystemTime('jan 2 00:01');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
vi.setSystemTime('jan 2 00:03');
start();
let state = getState();
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
// and elapsed still reflects only the 8 active minutes
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// 2 more active minutes after resume -> 10 minutes elapsed
vi.setSystemTime('jan 2 00:05');
update();
state = getState();
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
});
}); });
test('runtime offset', async () => { test('runtime offset', async () => {
+16 -21
View File
@@ -63,9 +63,7 @@ export type RuntimeState = {
// private properties of the timer calculations // private properties of the timer calculations
_timer: { _timer: {
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<Instant>; pausedAt: Maybe<TimeOfDay>;
/** Accumulate pause duration but dose not include the current pause */
pausedDuration: number; pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>; secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean; hasFinished: boolean;
@@ -78,12 +76,10 @@ export type RuntimeState = {
_end: ExpectedMetadata; _end: ExpectedMetadata;
_startEpoch: Maybe<Instant>; _startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>; _startDayOffset: Maybe<Day>;
_now: Instant;
}; };
const runtimeState: RuntimeState = { const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(), clock: timeCore.timeOfDayNow(),
_now: timeCore.now(),
groupNow: null, groupNow: null,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
@@ -108,12 +104,6 @@ const runtimeState: RuntimeState = {
_startDayOffset: null, _startDayOffset: null,
}; };
/** set the current clock to ensure parity between _now and clock */
function setClock(state: RuntimeState) {
state._now = timeCore.now();
state.clock = timeCore.toTimeOfDay(state._now);
}
export function getState(): Readonly<RuntimeState> { export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state // create a shallow copy of the state
return { return {
@@ -146,7 +136,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null; runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
setClock(runtimeState); runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer }; runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
@@ -179,7 +169,7 @@ export function clearState() {
runtimeState._end = null; runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
setClock(runtimeState); runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer }; runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
@@ -432,12 +422,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false; return false;
} }
setClock(state); const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null; state.timer.secondaryTimer = null;
// add paused time if it exists // add paused time if it exists
if (state._timer.pausedAt) { if (state._timer.pausedAt) {
const timeToAdd = state._now - state._timer.pausedAt; const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime += timeToAdd; state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd; state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null; state._timer.pausedAt = null;
@@ -454,7 +447,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
if (state.rundown.actualStart === null) { if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day; state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset; state.rundown.currentDay = state._startDayOffset;
state._startEpoch = state._now; state._startEpoch = epoch;
state.rundown.actualStart = state.clock; state.rundown.actualStart = state.clock;
} }
@@ -488,8 +481,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
} }
state.timer.playback = Playback.Pause; state.timer.playback = Playback.Pause;
setClock(state); state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state._now; state._timer.pausedAt = state.clock;
return true; return true;
} }
@@ -554,7 +547,9 @@ export type UpdateResult = {
export function update(): UpdateResult { export function update(): UpdateResult {
// 0. there are some things we always do // 0. there are some things we always do
const previousClock = runtimeState.clock; const previousClock = runtimeState.clock;
setClock(runtimeState); // we update the clock on every update call const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle? // 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) { if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -563,13 +558,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started) // calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) { if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now); const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart; runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
} }
// 2. are we waiting to roll? // 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock); const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
return updateIfWaitingToRoll(clockHasCrossedMidnight); return updateIfWaitingToRoll(clockHasCrossedMidnight);
} }
+85
View File
@@ -0,0 +1,85 @@
# Ontime architecture
Use for module placement or cross-boundary changes.
## Direction
Dependencies point toward pure domain logic:
```text
HTTP request
-> validation / router or controller
-> service orchestration
-> pure domain utilities
service orchestration
-> DAO / stores / adapters / external clients
```
Keep a layer only for clearer ownership, isolated side effects, or direct business-rule tests.
## Reuse and ownership
Before adding a module, helper, service, or interface, find the concept owner and inspect callers.
1. Reuse when the contract already matches.
2. Extend for the same concept when the contract stays coherent.
3. Keep local when reuse needs flags, broad optional inputs, unrelated modes, or leaky terms.
4. Generalise only after stable common behaviour appears across real callers.
Do not duplicate canonical rules or distort an abstraction to force reuse. Small local duplication can beat coupling unrelated concepts.
## Server
### Routers and controllers
Routers declare paths and middleware. Controllers map validated HTTP input to typed service arguments, then results/errors to responses.
Keep reusable calculations, domain decisions, transformations, persistence workflows, and integration coordination out of handlers. Simple reads may stay direct when a service would only pass through.
### Services
Services orchestrate use cases and side-effect boundaries. Make ordering and effects visible. Move substantial branching, calculation, comparison, parsing, and transformation to pure utilities.
### Pure utilities
Pass required state, config, and time explicitly. No I/O, stores/globals, logging, websocket publication, browser inspection, or caller-owned mutation unless explicitly contracted.
Use focused Vitest coverage. Colocate feature logic. Move to `ontime-utils` only for genuine cross-package use.
### State and boundaries
- DAOs/data providers: persistence.
- Stores: mutable runtime state.
- Adapters/clients: external protocols and integrations.
- Validators/parsers: protect boundaries before domain logic.
- Commit state before dependent notifications or invalidations.
Old layering exceptions are context, not precedent. Improve touched boundaries only through focused, behaviour-preserving moves.
## Client
- `common/api`: HTTP transport.
- `common/hooks-query`: TanStack Query reads, mutations, keys, cache, invalidation.
- `features`: reusable product capabilities and domain behaviour.
- `views`: route-level composition.
- `common`: genuinely cross-feature code.
Keep substantial rules out of JSX, effects, and handlers. Use tested, colocated pure utilities. TanStack Query owns server state; established Zustand/context owns local state. No parallel caches.
Limit subscriptions with selectors. Keep effect dependencies stable. Clean up listeners, intervals, external resources.
## Shared packages
- `ontime-types`: shared contracts; type-focused.
- `ontime-utils`: environment-independent, side-effect-free shared logic.
- Never import application layers into shared packages.
- Keep feature-specific helpers with their owner, even when used by another file.
## Review prompts
- Business rule understandable/testable without app startup?
- Transport, orchestration, state, transformation separated?
- Existing owner reused without forcing unrelated behaviour?
- Abstraction removes concepts rather than relocating them?
- Smallest focused remedy clear?
+30
View File
@@ -0,0 +1,30 @@
# Change assessment
Use before non-trivial planning/implementation and during non-trivial review. Let it shape scope and verification; avoid process theatre.
## Rate three dimensions
- **Value** — concrete user, product, operational, or maintenance benefit; include urgency.
- **Risk** — regression likelihood/impact: data loss, security, cloud incompatibility, disruption, hard rollback.
- **Complexity** — concepts, dependencies, layers, states, verification surfaces; not line count.
Use `Low`, `Medium`, or `High`. Give one evidence-based sentence each. No pseudo-precise scores.
```markdown
## Assessment
- Value: High — <concrete benefit>
- Risk: Medium — <failure modes and reversibility>
- Complexity: Low — <conceptual and verification burden>
- Recommendation: Proceed | Reshape | Defer — <why>
```
One assessment per overall change.
- High value never excuses unmanaged risk/complexity.
- High risk needs earlier proof, narrower increments, rollback, or stronger checks.
- High complexity needs clearer boundaries and smaller steps, not automatic abstraction.
- Low value plus high risk/complexity suggests reshape or defer.
- Revise after material scope discovery.
Plans: assessment before steps. Reviews: findings first, then assessment. Assessment informs; never replaces user intent or evidence.
+52
View File
@@ -0,0 +1,52 @@
# Simplicity and maintainability
Use for abstractions, comments, helpers, naming, or structural complexity.
## Simplicity
Choose the smallest explicit, testable design. No extension points, generic engines, wrappers, or config for hypothetical needs.
- Prefer direct flow over clever expressions or scattered conditions.
- Extract only to name a rule, enable pure tests, or remove meaningful duplication.
- Keep abstractions only when they reduce reader-held concepts.
- Reuse the concept owner when semantics match.
- Do not add flags, optional branches, generic names, or extension points to merge unlike cases.
- Prefer focused local helpers over generic APIs exposing unrelated modes.
- Keep scope narrow; note unrelated cleanup.
- Delete clearly obsolete branches, harnesses, and shims. Ask when ownership or compatibility is unclear.
## Comments
Keep comments for:
- non-obvious intent or invariants;
- necessary ordering, timing, mutation, or side effects;
- browser, Electron, cloud, or protocol constraints;
- workaround reasons and removal conditions;
- public contracts names/types cannot express.
Remove comments that:
- narrate code or names;
- number obvious steps;
- explain mechanics better expressed by code;
- preserve removed history;
- claim untested behaviour;
- use banners to hide oversized modules.
Update adjacent comments with code. Stale comments are defects.
Add a short comment immediately above every React `useEffect` describing the external synchronization or lifecycle
responsibility it owns. Explain non-obvious reasons or constraints when they matter; for files with multiple effects, a
brief responsibility label is useful even when the mechanics are straightforward.
## Naming and types
- Prefer Ontime terms over vague `data`, `result`, `item`.
- Prefer explicit/discriminated types over `any`, broad casts, optional fields, non-null assertions, silent fallbacks.
- Handle unions/enums exhaustively when future cases could be unsafe.
- Keep public functions focused enough to avoid long contract explanations.
## Review standard
Do not demand personal style. Report complexity only when it risks maintenance, hides rules, blocks focused tests, duplicates ownership, or makes changes unsafe. Suggest reuse only after verifying the existing contract fits without added genericity.
+46
View File
@@ -0,0 +1,46 @@
# Ontime domain invariants
Load only for touched domains. Add only stable, recurring invariants; not one-off bugs.
## Rundowns and entries
- Keep `entries`, `order`, `flatOrder` normalised.
- Keep group membership, group entry lists, child `parent` references consistent.
- Preserve entry identity and supported types across patch, clone, group, ungroup, reorder.
- Distinguish loaded vs background rundown. Prefer explicit rundown ID over global current state.
- No caller-owned rundown mutation unless explicitly contracted.
## Persistence, realtime, cache
- No partial commit on failure.
- Preserve revision/transaction semantics for loaded and background rundowns.
- Persist before websocket refetches, runtime updates, integration notifications, or cache assumptions.
- Notify only invalidated consumers; never leave client cache stale.
- Avoid duplicate listeners, notifications, invalidations, lifecycle effects.
- Reconnect/refetch must converge on authoritative state.
- Align query keys and websocket refetch keys with the changed resource.
## Timers
Use temporal values by meaning: `Instant` for epoch time, `TimeOfDay` for local time since midnight, `Duration` for elapsed time, `Day` for calendar offsets. Convert through `timeCore`; never interchange as raw numbers.
Active work: [runtimeState time-core migration](../migrations/runtime-state-time-core.md).
When relevant, cover interactions among:
- midnight/day offsets;
- linked events/gaps;
- delays/skipped entries;
- count-to-end;
- absolute/relative offsets;
- warning, danger, finish, roll, end-action transitions;
- loaded/next-event state.
Pass time/state explicitly to keep rules deterministic and unit-testable.
## Imports and migrations
- Treat project files, spreadsheets, custom fields, migrated data as untrusted.
- Preserve fields the import/migration does not own.
- Validate/parse into the current model before runtime logic.
- Avoid source mutation; test round trips and non-mutation when preservation matters.
+49
View File
@@ -0,0 +1,49 @@
# Routing and Ontime Cloud
Use for navigation, URLs, endpoints, websockets, auth, cookies, assets, redirects, presets, or local-storage scope.
## Deployment invariant
Support root and runtime-prefixed deployments:
```text
local: http://localhost:4001/timer
cloud: https://cloud.example/client-hash/timer
```
Prefix is deployment data. Never assume `/`.
## Client
- `apps/client/src/externals.ts`: derives `baseURI`, `serverURL`, `websocketUrl` from document base/current origin.
- `BrowserRouter`: uses `baseURI` basename.
- APIs/assets: use `common/api/constants.ts` or base-aware helpers.
- App navigation: use React Router. Never strip/guess/re-add prefix from `window.location.pathname`.
- Persisted browser state: use existing base-aware scoping where prefixes need isolation.
## Server
- `updateRouterPrefix()` in `apps/server/src/externals.ts`: normalises `ROUTER_PREFIX`.
- `apps/server/src/app.ts`: mounts routes below that prefix.
- Domain routers: paths relative to mount; never derive prefix.
- Websockets, auth redirects, cookie paths, share URLs: preserve prefix.
## URL rules
Use `URL`, React Router, or existing helpers instead of string manipulation. Preserve:
- leading/trailing slashes and runtime prefix;
- query params, auth tokens, navigation locks;
- preset aliases and canonical view paths;
- `https`/`wss` behind proxies;
- static/user asset paths.
Never infer Ontime Cloud from hostname alone. Generated base markup marks cloud; runtime prefixes also serve non-cloud reverse proxies.
## Cloud capabilities
Gate unavailable local-network integrations in cloud, including OSC output. Keep server behaviour and UI availability aligned.
## Verification
Test both root and a prefix such as `/client-hash`. Include relevant queries, redirects, cookies, websocket paths, presets, assets. Root-only routing coverage is incomplete.

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