Compare commits

...

10 Commits

Author SHA1 Message Date
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
72 changed files with 3096 additions and 2571 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.
+8 -7
View File
@@ -4,7 +4,7 @@
"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:"
} }
@@ -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,34 +54,40 @@ 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}>
<MenuGroup label='This screen'>
{supportsFullscreen && ( {supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}> <NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)} )}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}> <NavigationMenuToggle
Flip Screen checked={mirror}
<IoSwapVertical /> icon={<IoSwapVertical />}
{mirror && <span className={style.note}>Active</span>} label='Flip Screen'
</NavigationMenuItem> onToggle={() => toggleMirror()}
/>
{canUseWakeLock && ( {canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}> <NavigationMenuToggle
Keep Awake checked={keepAwake}
<LuCoffee /> icon={<LuCoffee />}
{keepAwake && <span className={style.note}>Active</span>} label='Keep Awake'
</NavigationMenuItem> onToggle={toggleKeepAwake}
/>
)} )}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem> <NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
<hr className={style.separator} /> Rename Client
</NavigationMenuItem>
</MenuGroup>
<MenuGroup label='Ontime app'>
<EditorNavigation /> <EditorNavigation />
<ClientLink <ClientLink
to='cuesheet' to='cuesheet'
@@ -89,13 +97,17 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> </ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}> <ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
</MenuGroup>
<hr className={style.separator} /> <MenuGroup label='Views'>
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink <ClientLink
key={route.url} key={route.url}
@@ -106,21 +118,29 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </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}>
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}> <span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} /> <span className={style.address}>{nif.address}</span>
<IoArrowUp className={style.goIcon} />
</span> </span>
</CopyTag> </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}>
<div className={style.header}>
<Eyebrow>Saved presets</Eyebrow>
</div>
<div className={style.presetList}>
{viewPresets.map((preset) => { {viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias; const active = searchParams.get('alias') === preset.alias;
return ( return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}> <div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div> <span className={style.presetName}>{preset.alias}</span>
{active ? (
<span className={style.current}>Current</span>
) : (
<Button <Button
variant={active ? 'ghosted' : 'subtle-white'} variant='ghosted'
size='small'
onClick={() => handleRecall(preset)} onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions} className={style.presetActions}
> >
{active ? 'Applied' : 'Apply'} Apply
</Button> </Button>
)}
</div> </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>
); );
} }
+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;
@@ -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';
@@ -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
@@ -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>
); );
})} })}
@@ -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);
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>)
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />; return <EditableImage initialValue={initialValue} 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 +207,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 +215,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 +255,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[]>({
+1 -1
View File
@@ -169,7 +169,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',
}, },
+2 -2
View File
@@ -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 .",
+3 -3
View File
@@ -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:"
}, },
@@ -818,7 +818,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
@@ -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,
} }
/** /**
-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);
};
}
+2 -5
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,
async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'], 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(
@@ -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'));
},
);
});
+39 -42
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);
if (tokenFromCookie === hashedPassword) {
return next(); 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) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
if (tokenFromCookie === hashedPassword) {
return next(); 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 {
const url = new URL(urlString, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (token === hashedPassword) {
return next(); return next();
} }
} catch (_) {
// ignore URL parsing errors if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
return next();
} }
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;
}
}
@@ -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();
});
});
+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.
+48
View File
@@ -0,0 +1,48 @@
# 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.
## 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.
+42
View File
@@ -0,0 +1,42 @@
# Security boundaries
Use for auth, external input, files, integrations, assets, URLs, or secrets.
## Untrusted inputs
Validate/parse before trust:
- HTTP bodies, params, headers, cookies, websocket messages;
- project files, migrations, spreadsheets, imports;
- custom HTML/CSS/views, translations, served assets;
- automation payloads, third-party responses;
- externally supplied paths/filenames.
Validate shape and domain constraints at the owning boundary. Keep existing parser/validation layers; avoid downstream defensive casts.
## Authentication and authorisation
- Keep protected routers behind auth middleware in `apps/server/src/app.ts`.
- Preserve auth across prefixed routes, redirects, websockets, generated links.
- Scope session cookies to runtime prefix; isolate hosted clients.
- Authentication never grants arbitrary file, path, project, or rundown access.
## Files, URLs, integrations
- Use established path/file helpers. Reject traversal and unexpected types.
- Build URLs with `URL` or existing helpers. Check open redirects, SSRF, protocol changes, token leaks.
- Encode/constrain untrusted HTML, CSS, filenames, headers, log values.
- Preserve cloud limits on local-network capabilities.
## Secrets and diagnostics
- Never commit/log passwords, hashes, tokens, credentials, cookies, private project content.
- Errors: useful, but no internal paths, stacks, credentials, sensitive payloads.
- Keep tokens in existing session/authenticated-share flows. Avoid new URL-token patterns.
## Review prompts
- Where does input become trusted?
- Validation once at owner, then useful type?
- Can one prefixed client cross another client's session/assets?
- Can logs, responses, redirects, URLs leak secrets?
+52
View File
@@ -0,0 +1,52 @@
# Testing strategy
Use for changed behaviour or tests.
## Layers
### Pure unit
Put detailed business-rule coverage on pure functions. Test public inputs/outputs with Vitest: relevant boundaries, invalid input, ordering, rollover, errors.
Every bug fix needs a regression test that fails before the fix. Prefer behaviour over mocks/implementation assertions.
### Service and state
Use service, DAO, store, or hook tests for orchestration: transitions, transactions, persistence, cache, notifications, external effects.
Do not repeat all pure cases here. Prove delegation and sequencing.
### End-to-end
Reserve Playwright for key journeys and high-risk cross-layer integrations: edit/run rundown, playback, imports, rundown switching, cloud-prefixed navigation.
No E2E for edges already proven in lower layers. Add E2E only when lower layers cannot prove the user-facing integration.
## Compact before handoff
Development harnesses may be broad, repetitive, diagnostic, temporary. Before handoff:
1. Identify distinct required behaviours/regressions.
2. Keep the smallest readable set that catches them.
3. Parameterise repetition only when the table reads better.
4. Remove diagnostic assertions, redundant permutations, temporary fixtures, private-detail coupling.
5. Keep rare cases that encode real domain rules.
Optimise for future readers, not minimum line count.
## Quality
- Name tests by observable behaviour.
- Keep setup local/explicit unless a fixture improves comprehension.
- Avoid arbitrary waits, wall-clock dependence, cross-test state, weak assertions.
- Prefer realistic typed fixtures over large snapshots or masking casts.
- Test non-mutation when promised.
- No tests for trivial type/format changes or framework behaviour Ontime does not own.
## Review prompts
- Business logic directly unit-testable?
- Test catches the reported bug/regression?
- Cases distinct, not repeated path?
- Temporary harness leaked?
- E2E justified by cross-layer risk?
+69
View File
@@ -0,0 +1,69 @@
# Workflow and repository conventions
Use for commands, imports, formatting, CI, dependencies, PRs.
## Workspaces
Confirm names from `package.json`.
| Package | Workspace name |
| ---------------- | --------------------- |
| Client | `ontime-ui` |
| Server | `ontime-server` |
| Electron | `ontime-electron` |
| Resolver | `@getontime/resolver` |
| CLI | `@getontime/cli` |
| Shared types | `ontime-types` |
| Shared utilities | `ontime-utils` |
Run from repo root: `pnpm --filter <workspace-name> <script>`. Add/install workspaces only when required.
## Verification
Start narrow; expand with risk:
```text
focused test
-> affected package tests
-> package lint/typecheck
-> required repository checks
```
```bash
pnpm --filter ontime-ui test:pipeline <path-to-test>
pnpm --filter ontime-server test:pipeline <path-to-test>
pnpm --filter ontime-utils test:pipeline <path-to-test>
pnpm --filter <workspace-name> lint
pnpm --filter <workspace-name> typecheck
pnpm format:check
pnpm lint
pnpm typecheck
pnpm test
pnpm e2e
```
PR CI: `.github/workflows/test.yml`; runs format, lint, types, unit, Playwright. Run E2E locally only for key flows or E2E infrastructure.
Before code commit: `pnpm lint`, `pnpm test`; add `pnpm typecheck` for TypeScript and `pnpm format:check` for formatted files. Claim only observed results.
## TypeScript and imports
- Strict TypeScript. Prefer `ontime-types` domain types over local copies.
- Keep client/server payloads aligned through `ontime-types`.
- Import `ontime-types`/`ontime-utils` from public entry points; Oxlint forbids `src` subpaths.
- Server relative ESM imports need `.js`.
- Reuse public exports/canonical helpers before adding utilities.
## Formatting and dependencies
- Use Oxlint/Oxfmt, not ESLint/Prettier.
- Oxfmt: two spaces, semicolons, single quotes, trailing commas, 120 columns.
- Add dependencies only when platform/current stack cannot solve the need. Review `package.json` and `pnpm-lock.yaml` together.
- Never hand-edit lockfile. No build output unless already tracked.
## Pull requests
- Title: `[<workspace-name>] <Title>`.
- Separate behaviour from unrelated refactors/format churn.
- State what changed, why, verification.
@@ -0,0 +1,67 @@
# runtimeState time-core migration
**Status:** In progress
## Goal
Make temporal meaning explicit across `runtimeState` and timer calculations. Prevent mixing:
- `Instant`: epoch timestamp;
- `TimeOfDay`: local milliseconds since midnight, range `[0, dayInMs)`;
- `Duration`: elapsed or remaining milliseconds;
- `Day`: calendar-day offset.
Use `apps/server/src/lib/time-core/timeCore.ts` for conversions and temporal arithmetic. Branded types remain numbers at serialization boundaries.
## Current state
Completed foundation:
- temporal brands in `ontime-types`;
- `timeCore` helpers for now, conversion, duration arithmetic, midnight crossing, calendar-day distance;
- partial `runtimeState` adoption of `Instant`, `TimeOfDay`, `Day`, and `timeCore`.
Remaining ambiguity:
- `TimerState`, `RundownState`, and `Offset` expose temporal fields as `number`/`MaybeNumber`;
- `timerUtils` accepts/returns raw numbers with different meanings;
- `runtimeState` retains raw duration fields, manual arithmetic, and `as TimeOfDay`/`as Duration` casts.
## Migration rules
- Classify each temporal field before changing it. No generic `Time` type.
- Convert only through `timeCore` or an explicit transport adapter.
- Keep public/websocket JSON numeric where required; brand at the boundary.
- Keep type migration separate from behaviour changes.
- Preserve current midnight, rollover, pause, add-time, roll, and offset behaviour per slice.
- Add helpers only when they encode a named temporal rule and remove caller casts/arithmetic.
## Slices
- [x] Add temporal brands and initial `timeCore` helpers/tests.
- [ ] Inventory temporal fields in `TimerState`, `RundownState`, `Offset`, and private runtime state; assign intended types.
- [ ] Migrate pure `timerUtils` functions by temporal concept; add focused midnight/rollover tests.
- [ ] Migrate private `RuntimeState` fields and calculations; remove local casts/manual conversions.
- [ ] Migrate shared runtime contracts and add numeric transport adapters where compatibility requires them.
- [ ] Update remaining callers, fixtures, and mocks.
- [ ] Remove superseded helpers, casts, and ambiguous temporal numbers.
Each slice must leave old/new boundaries explicit, compile cleanly, and preserve behaviour.
## Required coverage
- before, at, and after midnight;
- overnight and multi-day rundowns;
- local-day calculation across timezone/DST offset changes;
- pause/resume, added time, elapsed/remaining duration;
- roll secondary targets and expected finish;
- absolute/relative offsets and day offsets.
## Complete when
- Runtime/timer boundaries use semantic temporal types or documented numeric transport fields.
- Temporal conversions/arithmetic use `timeCore` or named pure helpers.
- No unexplained temporal casts or ambiguous numeric fields remain in migrated scope.
- Focused tests cover the required boundaries.
After completion, remove this migration tracker. Keep durable rules in `docs/agent-guides/domain-invariants.md`.
+41
View File
@@ -48,6 +48,47 @@ test.describe('test view navigation feature', () => {
await expect(page).toHaveURL('/timer'); await expect(page).toHaveURL('/timer');
}); });
/**
* The shortcut has to read the current open state, not the one captured when it was
* registered, otherwise it only ever opens the menu.
*/
test('Space closes the menu as well as opening it', async ({ page }) => {
const menu = page.getByRole('dialog');
await expect(menu).toBeHidden();
await openNavigationMenu(page);
await expect(menu).toBeVisible();
await page.keyboard.press('Space');
await expect(menu).toBeHidden();
});
test('Space closes a menu opened with the button', async ({ page }) => {
await page.mouse.move(Math.random() * 100, Math.random() * 100);
await page.getByTestId('navigation__toggle-menu').click();
const menu = page.getByRole('dialog');
await expect(menu).toBeVisible();
await page.keyboard.press('Space');
await expect(menu).toBeHidden();
});
test('Space toggles a focused menu switch without closing the menu', async ({ page }) => {
await openNavigationMenu(page);
const menu = page.getByRole('dialog');
const flipScreen = page.getByRole('switch', { name: 'Flip Screen' });
await expect(menu).toBeVisible();
const initiallyChecked = await flipScreen.getAttribute('aria-checked');
await flipScreen.focus();
await page.keyboard.press('Space');
await expect(flipScreen).toHaveAttribute('aria-checked', initiallyChecked === 'true' ? 'false' : 'true');
await expect(menu).toBeVisible();
});
test('not-found', async ({ page }) => { test('not-found', async ({ page }) => {
await page.goto('/not-found'); await page.goto('/not-found');
+19
View File
@@ -6,6 +6,25 @@ test('cuesheet displays events', async ({ page }) => {
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible(); await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
}); });
test('cuesheet persists column visibility', async ({ page }) => {
await page.goto('/cuesheet');
const noteHeader = page.getByRole('columnheader', { name: 'Note', exact: true });
const noteCell = page.getByTestId('cuesheet-event').first().getByTestId('cuesheet-cell-note');
await expect(noteHeader).toBeVisible();
await expect(noteCell).toBeVisible();
await page.getByRole('button', { name: 'Columns' }).click();
await page.getByRole('checkbox', { name: 'Note', exact: true }).click();
await expect(noteHeader).toBeHidden();
await expect(noteCell).toBeHidden();
await page.reload();
await expect(noteHeader).toBeHidden();
await expect(noteCell).toBeHidden();
});
test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => { test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => {
await page.goto('/cuesheet'); await page.goto('/cuesheet');
+1 -1
View File
@@ -44,7 +44,7 @@ test.describe('URL Preset', () => {
.filter({ hasText: /^testingApply$/ }) .filter({ hasText: /^testingApply$/ })
.getByRole('button') .getByRole('button')
.click(); .click();
await expect(page.getByRole('button', { name: 'Applied' })).toBeVisible(); await expect(page.getByText('Current')).toBeVisible();
// 2. the URL contains the preset // 2. the URL contains the preset
expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy(); expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy();
@@ -13,3 +13,28 @@ test('View params configures timer view', async ({ page }) => {
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport(); await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
await expect(page).toHaveURL(/.*hideClock=true/); await expect(page).toHaveURL(/.*hideClock=true/);
}); });
/**
* The form gathers its values from the DOM, so a collapsed section has to stay mounted.
* Unmounting it would quietly drop everything the user set in it on the next apply.
*/
test('View params keeps the values of collapsed sections', async ({ page }) => {
// hideClock lives in the section we are about to collapse
await page.goto('/timer?hideClock=true');
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
await page.mouse.move(Math.random() * 100, Math.random() * 100);
await page.getByTestId('navigation__toggle-settings').click();
const section = page.getByRole('button', { name: /Element visibility/ });
await expect(section).toHaveAttribute('aria-expanded', 'true');
await section.focus();
await page.keyboard.press('Space');
await expect(section).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('dialog', { name: 'Ontime' })).toBeHidden();
await page.getByTestId('apply-view-params').click();
await expect(page).toHaveURL(/.*hideClock=true/);
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
});
+5 -5
View File
@@ -41,14 +41,14 @@
"format:check": "oxfmt --check" "format:check": "oxfmt --check"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.60.0", "@playwright/test": "1.62.1",
"@types/node": "catalog:", "@types/node": "catalog:",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"oxfmt": "^0.42.0", "oxfmt": "^0.63.0",
"oxlint": "^1.57.0", "oxlint": "^1.78.0",
"oxlint-tsgolint": "^0.17.4", "oxlint-tsgolint": "^7.0.2001",
"rimraf": "catalog:", "rimraf": "catalog:",
"turbo": "2.8.20", "turbo": "2.10.10",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499", "packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
+1
View File
@@ -14,6 +14,7 @@
"nanoid": "^6.0.0" "nanoid": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
+1152 -2116
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,10 +4,10 @@ packages:
catalog: catalog:
'@types/node': 22.19.11 '@types/node': 22.19.11
rimraf: 6.0.1 rimraf: 6.1.3
ts-essentials: 10.1.1 ts-essentials: 10.2.1
typescript: 7.0.2 typescript: 7.0.2
vitest: 4.0.17 vitest: 4.1.10
allowBuilds: allowBuilds:
'@parcel/watcher': true '@parcel/watcher': true