Compare commits

..

21 Commits

Author SHA1 Message Date
Carlos Valente 9a6feed49b refactor: improve groups and time end 2026-06-28 09:08:24 +02:00
Carlos Valente c6cf9f2bf7 fixup! refactor: add to correct section 2026-06-28 08:48:14 +02:00
Carlos Valente 2f73303554 refactor: simplify object creation 2026-06-28 08:46:11 +02:00
Carlos Valente 96943ca528 refactor: add to correct section 2026-06-28 08:45:54 +02:00
Carlos Valente 8cfffd32c7 fix: duplicate rundowns from service layer 2026-06-27 16:30:04 +02:00
Carlos Valente 24a606a246 fixup! improve linting and documentation 2026-06-27 16:27:59 +02:00
Carlos Valente c322028821 docs 2026-06-27 16:27:59 +02:00
Carlos Valente 9f40d962ea improve linting and documentation 2026-06-27 16:27:59 +02:00
Claude 1e851ae40d refactor(mcp): port tools to current services with typed translation handlers
- Tool handlers are now a typed map of thin translation wrappers: wire
  arguments are cast once at the boundary to types derived (Pick) from
  ontime-types, and all service calls are fully typed — a change to a
  service signature or domain type now fails compilation in api-mcp
  instead of drifting silently. No more 'as never' casts.
- Entry mutations target the loaded rundown explicitly, matching the
  rundownId-first service signatures introduced with secondary rundowns.
- Rundown create/rename/delete/duplicate call the rundown service;
  mcp.service.ts and its duplicated router logic are deleted.
- ontime_create_entry supports all entry types (event, delay, milestone,
  group) and entry tools accept flag and custom field values, matching
  what the workflow prompts already instruct agents to do. Tool names
  use entry terminology consistently with the service layer.
- ontime_create_project description fixed: creating a project switches
  to it.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 2bcda2dfa4 refactor(mcp): scope bearer authentication to the MCP route
Reverts the change to the shared authenticate middleware, which made
every authenticated route accept Authorization: Bearer tokens. Bearer
support now lives in a wrapper middleware inside api-mcp, mounted only
on /mcp; all other requests fall through to the app middleware
unchanged. Removing the api-mcp module restores stock auth behaviour.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude e2a2ea1d24 refactor(rundown): extract rundown rename/delete/duplicate into service layer
Moves the logic previously inlined in the router handlers into
rundown.service.ts so other consumers (such as the MCP server) can reuse
it without duplication. Status codes and error messages are unchanged.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 5be596b83e refactor(mcp): dispatch map for resources, userPrompt helper, enrich prompt guidance
mcp.resources.ts: replace 6-branch if-chain with a RESOURCE_READERS dispatch map.
Each resource is now a one-liner { mimeType, read } entry; handleReadResource
collapses to a 3-line lookup with a single throw.

mcp.prompts.ts: extract repeated message-wrapping boilerplate into userPrompt()
helper, reducing each prompt from ~10 lines of structure to a single call.

Enrich all four prompts with operational domain knowledge:
- create_rundown_from_agenda: entry type selection (event/milestone/delay/group),
  timerType and endAction guidance, linkStart chain semantics, flag usage, colour
  conventions
- bulk_edit_rundown: timeStrategy (lock-end vs lock-duration), endAction automation
  risk, clarified time-shift mechanics
- validate_rundown: totalDelay/totalDays checks, linkStart chain validation,
  play-next chain disclosure, flag audit
- restructure_rundown: group awareness (entries vs order arrays), insert order type,
  move-out vs move-in patterns

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ab15a65585 simplify: unify 405 handlers, fix double-read in renameRundown, guard mcpClientConfig
- mcp.router.ts: extract identical GET/DELETE 405 responses into methodNotAllowed
- mcp.service.ts: pass the already-patched rundown object to initRundown instead
  of re-fetching it from the data provider
- McpSection.tsx: compute mcpClientConfig only when mcpEndpointUrl is truthy

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 65ef442a9d refactor(mcp): consolidate documentation into mcp.schema.ts
Single file to update when the data model changes:
- EVENT_TIMER_FIELDS and EVENT_WRITABLE_FIELDS (tool JSON schemas) moved from mcp.tools.ts
- ONTIME_SCHEMA_MARKDOWN and ONTIME_DOCS_MARKDOWN moved from mcp.resources.ts
- mcp.tools.ts and mcp.resources.ts now import from mcp.schema.ts
- Removed inline data model notes from the agenda prompt; replaced with a reference
  to the ontime://schema resource
- Fixed inaccurate enum values in schema (Milestone type added, TimerType/EndAction
  corrected to match ontime-types)

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 71a7f26493 refactor(mcp): address review — split files, fix naming, stateless router
Split the monolithic mcp.router.ts into focused modules:
- mcp.service.ts: coordination helpers (rundownListResponse, renameRundown, deleteRundown)
- mcp.tools.ts: tool definitions (ontime_ prefix, annotation presets, shared field schemas,
  CHARACTER_LIMIT truncation) + handleToolCall dispatcher
- mcp.prompts.ts: prompt definitions + handleGetPrompt
- mcp.resources.ts: resource definitions (schema, live data, docs) + handleReadResource
- mcp.server.ts: createMcpServer factory that wires the above modules
- mcp.router.ts: thin stateless Express router (POST/GET405/DELETE405)

Other changes per review comments:
- Rename ontime_delete_event → ontime_delete_entry (entry vs event naming convention)
- Remove hardcoded cue/colour prefix conventions from tool descriptions and prompts;
  replace with "ask the user what convention they prefer"
- Remove automations tools (left for a later PR)
- Add Bearer auth comment in authenticate.ts explaining the MCP use case
- Extract McpSection component from FeaturePanel to isolate URL state re-renders
- Update bulk_edit prompt to mention linkStart cascade behaviour
- Add 405 comments explaining why GET/DELETE are not supported in stateless mode

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude b850a51204 mcp: add ontime://docs resource for documentation discoverability
Exposes a curated index of docs.getontime.no topic URLs as the
ontime://docs MCP resource, and adds a "Further reading" footer to
ontime://schema so agents reading the data model know where to find
deeper documentation.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 0094c15da2 feat(mcp): expose rundown and schema as readable MCP resources
Adds five resources so the agent can load context once per session
instead of re-fetching via tools, and so it can ground its answers in
Ontime's data model without guessing:

- ontime://schema — markdown reference for the event/rundown/group
  model, time format (ms from midnight), cue conventions, colours,
  playback states
- ontime://rundown/current — full currently-loaded rundown (JSON)
- ontime://rundowns — every rundown in the project plus the loaded ID
- ontime://project/info — project metadata
- ontime://project/custom-fields — custom field definitions

Declares the resources capability on the MCP server and registers
resources/list and resources/read handlers that dispatch by URI.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude e7d62f44e7 feat(mcp): batch rundown edits, project file management, workflow prompts
Adds 8 tools and 3 prompts to cover four priority pre-production flows:
build-from-agenda (batch create), bulk edit, validate, restructure.

Tools:
- create_events_batch, batch_update_events — avoid per-event round trips
- list_projects, load_project, create_project, rename_project,
  duplicate_project, delete_project — project file management

Prompts:
- bulk_edit_rundown — teaches the agent to use batch_update_events vs
  per-event updates based on whether fields are shared
- validate_rundown — pre-show checks for cue gaps, overlaps, missing
  fields, duration anomalies
- restructure_rundown — teaches reorder_event sequencing
- create_rundown_from_agenda — updated to use create_events_batch

Safety:
- Mutating tools now attach a playback warning to their response when
  timer state is not stopped, so the agent can relay it to the user
- create_automation description mentions the in-app test button

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3c8d7e51f5 chore: ignore .claude/ metadata directory
https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3da3950fce fix(mcp): session ID mismatch, rename_rundown data loss, DELETE status
- Session ID: deterministic UUID passed to sessionIdGenerator so the
  map key matches the mcp-session-id header sent to clients; previously
  two independent randomUUID() calls produced different IDs, breaking
  all post-initialize requests
- rename_rundown: use spread { ...rundown, title } instead of narrow
  structuredClone cast that silently stripped order/entries/revision
- DELETE /mcp: return 204 No Content instead of 200 with body

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ac3694ce4d feat: add MCP server for AI agent integration
Adds a Streamable HTTP MCP server at /mcp that exposes 19 tools
covering rundown entries, rundown management, runtime state, project
info, automations, and custom fields. Includes Bearer token auth
support and a UI card in Settings > Sharing showing the endpoint URL
and Claude Desktop config.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
113 changed files with 1029 additions and 3240 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.10.0", "version": "4.9.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.10.0", "version": "4.9.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -1,14 +0,0 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -1,7 +0,0 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -38,6 +38,8 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar { .multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black); background-color: var(--background-color-override, $ui-black);
opacity: 0.8; opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & { .multiprogress-bar--ignore-css-override & {
background-color: $ui-black; background-color: $ui-black;
@@ -1,6 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress'; import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -35,7 +34,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete); const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -8,7 +8,6 @@ import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -106,8 +105,6 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && (
@@ -120,27 +117,3 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</Dialog.Root> </Dialog.Root>
); );
} }
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</>
);
}
@@ -1,8 +1,6 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
@@ -19,9 +17,6 @@ export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const savedParams = useSavedViewParams((store) => store.params);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
useHotkeys([ useHotkeys([
[ [
@@ -51,7 +46,6 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</> </>
@@ -1,29 +1,4 @@
.trailing {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.linkIcon { .linkIcon {
margin-left: auto;
@include rotate-fourty-five; @include rotate-fourty-five;
} }
.indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
flex-shrink: 0;
}
.clear {
opacity: 0.6;
transition: opacity $transition-time-action;
&:hover,
&:focus-visible {
opacity: 1;
}
}
@@ -1,16 +1,9 @@
import { MouseEvent, PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoArrowUp, IoCloseOutline } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { useLocation, useNavigate, useSearchParams } from 'react-router'; import { useNavigate } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent'; import { useElectronEvent } from '../../../hooks/useElectronEvent';
import {
hasCustomParams,
reservedParams,
stripReservedParams,
useSavedViewParams,
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss'; import style from './ClientLink.module.scss';
@@ -23,100 +16,32 @@ interface ClientLinkProps {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<ElectronNavigationItem current={current} to={to} postAction={postAction}> <NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
</ElectronNavigationItem> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
); );
} }
return ( return (
<BrowserNavigationItem current={current} to={to} postAction={postAction}> <NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</BrowserNavigationItem>
);
}
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigateToLink = () => {
handleLinks(to);
postAction?.();
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
<span className={style.trailing}>
<IoArrowUp className={style.linkIcon} />
</span>
</NavigationMenuItem>
);
}
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { params: savedParams, save, clear } = useSavedViewParams();
/**
* Save the params of the view we are leaving and resolve the destination,
* restoring any params previously saved for the target view.
*/
const resolveDestination = () => {
const currentView = location.pathname.replace(/^\//, '');
save(currentView, stripReservedParams(location.search));
const restored = savedParams[to];
return `${to}${restored ? `?${restored}` : ''}`;
};
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
const navigateToLink = () => {
const destination = resolveDestination();
navigate(`/${destination}`);
postAction?.();
};
/**
* Clear the saved settings for this route without navigating to it.
* When it is the current view, also reset the live URL to its defaults.
*/
const clearViewSettings = (event: MouseEvent) => {
event.stopPropagation();
clear(to);
// if mounted, clear the URL params
if (current) {
const preserved = new URLSearchParams();
reservedParams.forEach((key) => {
const value = searchParams.get(key);
if (value !== null) preserved.set(key, value);
});
setSearchParams(preserved);
}
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Clear saved view settings'
title='Clear saved view settings'
onClick={clearViewSettings}
>
<IoCloseOutline />
</IconButton>
</span>
)}
</NavigationMenuItem> </NavigationMenuItem>
); );
} }
@@ -22,19 +22,3 @@
top: 0; top: 0;
z-index: $zindex-nav; z-index: $zindex-nav;
} }
.buttonWithIndicator {
position: relative;
display: inline-flex;
}
.indicator {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
}
@@ -9,10 +9,9 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps { interface FloatingNavigationProps {
toggleMenu?: () => void; toggleMenu?: () => void;
toggleSettings?: () => void; toggleSettings?: () => void;
hasSavedChanges?: boolean;
} }
export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) { export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true); const isButtonShown = useFadeOutOnInactivity(true);
return ( return (
@@ -21,18 +20,15 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSave
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])} className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
> >
{toggleMenu && ( {toggleMenu && (
<div className={style.buttonWithIndicator}> <IconButton
<IconButton variant='subtle-white'
variant='subtle-white' size='xlarge'
size='xlarge' onClick={toggleMenu}
onClick={toggleMenu} aria-label='toggle menu'
aria-label='toggle menu' data-testid='navigation__toggle-menu'
data-testid='navigation__toggle-menu' >
> <IoApps />
<IoApps /> </IconButton>
</IconButton>
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
</div>
)} )}
{toggleSettings && ( {toggleSettings && (
<IconButton <IconButton
@@ -14,4 +14,6 @@ $progress-bar-br: 3px;
.progress-bar__indicator { .progress-bar__indicator {
height: $progress-bar-size; height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color); background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
} }
@@ -1,6 +1,6 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress'; import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss'; import './ProgressBar.scss';
@@ -12,7 +12,7 @@ interface ProgressBarProps {
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props; const { current, duration, className } = props;
const progress = useAnimatedProgress(current, duration); const progress = getProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -4,9 +4,7 @@
color: $white-10; color: $white-10;
.empty { .empty {
display: block; width: 100%;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8; opacity: 0.8;
} }
@@ -15,6 +13,6 @@
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 600;
font-size: 2em; font-size: 2em;
max-width: min(100%, 600px); max-width: 600px;
} }
} }
@@ -1,41 +0,0 @@
import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0);
// there is only something to animate while a running timer is counting down towards 0
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
// re-anchor to the authoritative value whenever the server pushes a new timer update
useEffect(() => {
baseline.current = { current, at: performance.now() };
}, [current, duration, playback]);
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration);
}
@@ -1,87 +0,0 @@
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
describe('savedViewParams store', () => {
beforeEach(() => {
useSavedViewParams.getState().clearAll();
});
it('saves and restores params per view', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
});
it('overwrites the saved params for a view on subsequent saves', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
});
it('does not store an entry for an empty search string', () => {
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('removes a previously saved entry when saved with an empty search string', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('ignores empty view keys', () => {
useSavedViewParams.getState().save('', 'hideSeconds=true');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('clears the saved params for a single view without touching others', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('is a no-op when clearing a view without saved params', () => {
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('clears all saved params', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().clearAll();
expect(useSavedViewParams.getState().params).toEqual({});
});
});
describe('stripReservedParams', () => {
it('removes reserved auth/preset params while keeping view customisation', () => {
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
});
it('returns an empty string when only reserved params are present', () => {
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
});
});
describe('hasCustomParams', () => {
it('is true when a non-reserved param is present', () => {
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
});
it('is false when only reserved params are present', () => {
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
});
it('is false when there are no params', () => {
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
});
});
@@ -1,62 +0,0 @@
import { create } from 'zustand';
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
export const reservedParams = new Set(['token', 'n', 'alias']);
interface SavedViewParamsStore {
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
save: (view: string, search: string) => void;
clear: (view: string) => void;
clearAll: () => void;
}
/**
* Remembers the last view parameters used for each view so they can be
* restored when the user navigates back to that view through the menu.
* In-memory only: persists across SPA navigation, resets on a full page reload.
*/
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
params: {},
save: (view, search) =>
set((state) => {
// ignore empty view keys and do not store empty entries, so the
// "saved changes" indicator only reflects genuine customisation
if (!view) return state;
const params = { ...state.params };
if (search) {
params[view] = search;
} else {
delete params[view];
}
return { params };
}),
clear: (view) =>
set((state) => {
if (!state.params[view]) return state;
const params = { ...state.params };
delete params[view];
return { params };
}),
clearAll: () => set({ params: {} }),
}));
/**
* Removes reserved (auth/preset) params from a search string, keeping only
* genuine view customisation.
*/
export function stripReservedParams(search: string): string {
const sp = new URLSearchParams(search);
reservedParams.forEach((key) => sp.delete(key));
return sp.toString();
}
/**
* Whether the search params contain any genuine view customisation,
* ignoring reserved params.
*/
export function hasCustomParams(searchParams: URLSearchParams): boolean {
for (const key of searchParams.keys()) {
if (!reservedParams.has(key)) return true;
}
return false;
}
@@ -1,4 +1,4 @@
import { hostToBaseUrl, linkToOtherHost } from '../linkUtils'; import { linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => { describe('linkToOTherHost', () => {
it('should handle electron links', () => { it('should handle electron links', () => {
@@ -22,18 +22,3 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path'); expect(destination).toBe('https://app.getontime.no/user-hash/path');
}); });
}); });
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
@@ -37,7 +37,6 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage', alias: 'demopage',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -120,7 +119,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6', alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -133,7 +131,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic', alias: 'cuesheet-basic',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}, },
]; ];
const cuesheetPresetWithNavLock: URLPreset[] = [ const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -142,7 +139,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked', alias: 'cuesheet-locked',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: 'n=1', search: 'n=1',
displayInNav: false,
}, },
]; ];
@@ -224,7 +220,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -236,7 +231,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -248,7 +242,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -260,7 +253,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
])('should generate URL preset options for %s', (_description, alias, url, expected) => { ])('should generate URL preset options for %s', (_description, alias, url, expected) => {
+1 -20
View File
@@ -37,12 +37,7 @@ export function handleLinks(
event?.preventDefault(); event?.preventDefault();
const destination = new URL(externalServerUrl); const destination = new URL(externalServerUrl);
// split off any query string so it is not encoded into the pathname destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
const [pathname, search] = location.split('?');
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
if (search) {
destination.search = search;
}
openLink(destination.toString()); openLink(destination.toString());
} }
@@ -59,17 +54,3 @@ export function linkToOtherHost(
} }
return destination.toString(); return destination.toString();
} }
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
@@ -194,7 +194,6 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
displayInNav: false,
}; };
} }
@@ -38,7 +38,7 @@ $inner-padding: 1rem;
.section { .section {
position: relative; position: relative;
margin-top: 2rem; margin-top: 2rem;
max-width: 1024px; max-width: 850px;
} }
.indent { .indent {
@@ -1,8 +1,7 @@
import { OntimeView, URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -23,23 +22,13 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() { export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets(); const { data, status } = useUrlPresets();
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset(); const { deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset }); const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined }); const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
await updatePreset(preset.alias, preset);
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
@@ -64,12 +53,10 @@ export default function URLPresets() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />} {formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th>Enabled</th> <th>Enabled</th>
<th>Show in nav</th>
<th>Target view</th> <th>Target view</th>
<th>Alias</th> <th>Alias</th>
<th /> <th />
@@ -78,23 +65,10 @@ export default function URLPresets() {
<tbody> <tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />} {data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.map((preset, index) => { {data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return ( return (
<tr key={preset.alias}> <tr key={preset.alias}>
<td> <td>
<Switch <Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
checked={preset.enabled}
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
disabled={isMutating}
aria-label='Toggle preset enabled'
/>
</td>
<td>
<Switch
checked={preset.displayInNav}
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
disabled={isMutating || isCuesheet}
/>
</td> </td>
<td> <td>
<Tag>{preset.target}</Tag> <Tag>{preset.target}</Tag>
@@ -1,5 +1,5 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils'; import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
@@ -12,7 +12,6 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
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 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';
@@ -33,7 +32,6 @@ const defaultValues: URLPreset = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: '', search: '',
enabled: true, enabled: true,
displayInNav: false,
}; };
interface URLPresetFormProps { interface URLPresetFormProps {
@@ -63,42 +61,12 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}); });
const urlRef = useRef<HTMLInputElement>(null); const urlRef = useRef<HTMLInputElement>(null);
// Cuesheet read/write permissions live outside react-hook-form
const initialPermissions = useRef<CuesheetPermissionValues>({
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
});
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
// update initial permissions on mount
useEffect(() => {
initialPermissions.current = {
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
};
setCuesheetPermissions(initialPermissions.current);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
}, []);
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
const isCuesheet = watch('target') === OntimeView.Cuesheet;
const permissionsDirty =
isCuesheet &&
(cuesheetPermissions.read !== initialPermissions.current.read ||
cuesheetPermissions.write !== initialPermissions.current.write);
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
const setupSubmit = async (data: URLPreset) => { const setupSubmit = async (data: URLPreset) => {
try { try {
// Preserve / apply cuesheet permissions, which are not part of the form fields
const payload: URLPreset =
data.target === OntimeView.Cuesheet
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
: data;
if (urlPreset) { if (urlPreset) {
await updatePreset(urlPreset.alias, payload); await updatePreset(urlPreset.alias, data);
} else { } else {
await addPreset(payload); await addPreset(data);
} }
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -106,7 +74,6 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
} }
}; };
// focus on alias when the form opens
useEffect(() => { useEffect(() => {
setFocus('alias'); setFocus('alias');
}, [setFocus]); }, [setFocus]);
@@ -159,66 +126,44 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<div className={style.expand}> <div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description> <Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements> <Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} /> <Input placeholder='Paste URL' fluid ref={urlRef} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}> <Button onClick={generateOptions}>Generate</Button>
Generate
</Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</Panel.InlineElements> </Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>} {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
{!isEditingCuesheet && ( <div>
<> {enDash} or {enDash}
<div> </div>
{enDash} or {enDash} <div>2. Choose a view and its parameters</div>
</div> <div>
<div>2. Choose a view and its parameters</div> <Panel.Description>Target</Panel.Description>
<div> <Select
<Panel.Description>Target</Panel.Description> options={targetOptions}
<Select {...register('target', { required: 'Target is required' })}
options={targetOptions} value={watch('target')}
{...register('target', { required: 'Target is required' })} onValueChange={(value: OntimeViewPresettable | null) => {
value={watch('target')} if (value === null) return;
onValueChange={(value: OntimeViewPresettable | null) => { setValue('target', value, { shouldDirty: true });
if (value === null) return; }}
setValue('target', value, { shouldDirty: true }); />
}} </div>
/> <div>
</div> <Panel.Description>Parameters</Panel.Description>
<div> <Textarea
<Panel.Description>Parameters</Panel.Description> fluid
<Textarea rows={3}
fluid {...register('search', {
rows={3} validate: validateParams,
{...register('search', { })}
validate: validateParams, />
})} <Panel.Error>{errors.search?.message}</Panel.Error>
/> </div>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
</>
)}
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
<div> <div>
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'> <Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button <Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
variant='primary'
type='submit'
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -2,23 +2,27 @@ import { useEffect, useState } from 'react';
import { generateUrl } from '../../../../common/api/session'; import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { serverURL } from '../../../../externals'; import useInfo from '../../../../common/hooks-query/useInfo';
import { isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function McpSection() { export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState(''); const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
// generate url
useEffect(() => { useEffect(() => {
// the page origin is reachable wherever the user is browsing from, const baseUrl = isOntimeCloud
// and follows the deployment (cloud, reverse proxy, port mappings) ? serverURL
// we are reusing the endpoint, so locking config and nav have no effect : infoData.networkInterfaces.length > 0
generateUrl({ baseUrl: serverURL, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false }) ? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
: serverURL;
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl) .then(setMcpEndpointUrl)
.catch(() => { .catch(() => {
setMcpEndpointUrl(''); setMcpEndpointUrl('');
}); });
}, []); }, [infoData]);
const mcpClientConfig = mcpEndpointUrl const mcpClientConfig = mcpEndpointUrl
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2) ? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
@@ -30,19 +34,14 @@ export default function McpSection() {
<Panel.SubHeader>MCP Server</Panel.SubHeader> <Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph> <Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider /> <Panel.Divider />
<Panel.ListGroup> <Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
<Panel.ListItem> {mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' /> <Panel.Divider />
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>} <Panel.Field
</Panel.ListItem> title='Client configuration snippet'
<Panel.ListItem> description='Paste this into your AI agent settings under "mcpServers"'
<Panel.Field />
title='Client configuration snippet' {mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
); );
@@ -1,5 +1,5 @@
import { TranslationObject, langEn } from 'ontime-types'; import { TranslationObject, langEn } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useMemo } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
@@ -10,8 +10,6 @@ import Modal from '../../../../../common/components/modal/Modal';
import { useTranslation } from '../../../../../translation/TranslationProvider'; import { useTranslation } from '../../../../../translation/TranslationProvider';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './CustomTranslationModal.module.scss';
interface CustomTranslationModalProps { interface CustomTranslationModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -19,7 +17,14 @@ interface CustomTranslationModalProps {
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) { export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
const { userTranslation, postUserTranslation } = useTranslation(); const { userTranslation, postUserTranslation } = useTranslation();
const customTranslationFormValues = useMemo(() => toFormValues(userTranslation), [userTranslation]);
const defaultValues = useMemo(() => {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
});
return values;
}, [userTranslation]);
const { const {
handleSubmit, handleSubmit,
@@ -28,17 +33,13 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
formState: { isSubmitting, isDirty, errors, isValid }, formState: { isSubmitting, isDirty, errors, isValid },
setError, setError,
} = useForm({ } = useForm({
defaultValues: customTranslationFormValues, defaultValues,
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange', mode: 'onChange',
}); });
// keep data up to date
useEffect(() => {
if (isOpen) {
reset(customTranslationFormValues);
}
}, [customTranslationFormValues, isOpen, reset]);
const onSubmit = async (formData: Record<string, string>) => { const onSubmit = async (formData: Record<string, string>) => {
try { try {
const translationData: Record<string, string> = {}; const translationData: Record<string, string> = {};
@@ -53,10 +54,6 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
} }
}; };
const resetToEnglish = () => {
reset(toFormValues(langEn), { keepDefaultValues: true });
};
return ( return (
<Modal <Modal
title='Edit custom translations' title='Edit custom translations'
@@ -87,12 +84,9 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
</Panel.Section> </Panel.Section>
} }
footerElements={ footerElements={
<div className={style.footer}> <div>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'> <Panel.InlineElements align='apart'>
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
Reset to English
</Button>
<Panel.InlineElements> <Panel.InlineElements>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Cancel Cancel
@@ -115,14 +109,6 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
); );
} }
function toFormValues(translation: Partial<TranslationObject>) {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = translation[key as keyof TranslationObject] || '';
});
return values;
}
function toFormKey(key: string) { function toFormKey(key: string) {
return key.replace('.', '_'); return key.replace('.', '_');
} }
@@ -180,13 +180,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup <OperatorGroup key={entry.id} title={entry.title} />
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => { {entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId]; const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) { if (!isOntimeEvent(nestedEntry)) {
@@ -223,7 +217,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
isLinkedToLoaded={isLinkedToLoaded} isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded} isSelected={isLoaded}
isPast={isPast} isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined} selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart} showStart={showStart}
subscribed={subscribedData} subscribed={subscribedData}
@@ -22,39 +22,17 @@
background-color: $gray-1250; background-color: $gray-1250;
} }
&.grouped {
position: relative;
padding-left: 0.35rem;
background:
linear-gradient(90deg, color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%), transparent 8rem),
$viewer-card-bg-color;
}
&.running { &.running {
border-top: 1px solid $gray-1300; border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-green); background-color: var(--operator-running-bg-override, $active-green);
} }
&.grouped.running {
background:
linear-gradient(90deg, color-mix(in srgb, transparent 82%, var(--group-colour, $gray-500) 18%), transparent 8rem),
var(--operator-running-bg-override, $active-green);
}
&.past { &.past {
border-top: 1px solid transparent; border-top: 1px solid transparent;
opacity: 0.2; opacity: 0.2;
} }
} }
.groupRail {
position: absolute;
inset-block: 0;
left: 0;
width: 0.35rem;
background-color: var(--group-colour, $gray-500);
}
.binder { .binder {
grid-area: binder; grid-area: binder;
color: $section-white; color: $section-white;
@@ -96,9 +74,6 @@
.plannedStart, .plannedStart,
.timeUntil, .timeUntil,
.runningTime { .runningTime {
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: $component-border-radius-md; border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
line-height: 1; line-height: 1;
@@ -120,21 +95,6 @@
letter-spacing: 1px; letter-spacing: 1px;
} }
.live {
color: $ui-black;
background-color: $ui-white;
}
.due {
color: $ui-black;
background-color: $orange-500;
}
.done {
color: $white-60;
background-color: $white-7;
}
.runningTime { .runningTime {
grid-area: running; grid-area: running;
font-size: 1.25rem; font-size: 1.25rem;
@@ -25,7 +25,6 @@ interface OperatorEventProps {
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
isSelected: boolean; isSelected: boolean;
isPast: boolean; isPast: boolean;
groupColour?: string;
selectedRef?: RefObject<HTMLDivElement | null>; selectedRef?: RefObject<HTMLDivElement | null>;
showStart: boolean; showStart: boolean;
subscribed: Subscribed; subscribed: Subscribed;
@@ -47,7 +46,6 @@ function OperatorEvent({
isLinkedToLoaded, isLinkedToLoaded,
isSelected, isSelected,
isPast, isPast,
groupColour,
selectedRef, selectedRef,
showStart, showStart,
subscribed, subscribed,
@@ -70,12 +68,7 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress); const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour); const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([ const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
style.event,
groupColour && style.grouped,
isSelected && style.running,
isPast && style.past,
]);
const hasFields = subscribed.some((field) => field.value); const hasFields = subscribed.some((field) => field.value);
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0; const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
@@ -92,10 +85,8 @@ function OperatorEvent({
data-testid={cue} data-testid={cue}
ref={selectedRef} ref={selectedRef}
onContextMenu={handleLongPress} onContextMenu={handleLongPress}
style={groupColour ? ({ '--group-colour': groupColour } as CSSProperties) : undefined}
{...mouseHandlers} {...mouseHandlers}
> >
{groupColour && <div className={style.groupRail} />}
<div className={style.binder} style={{ ...cueColours }}> <div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>
@@ -162,11 +153,11 @@ function OperatorEventSchedule({
isLinkedToLoaded, isLinkedToLoaded,
}: OperatorEventScheduleProps) { }: OperatorEventScheduleProps) {
if (isPast) { if (isPast) {
return <span className={cx([style.timeUntil, style.done])}>DONE</span>; return <span className={style.timeUntil}>DONE</span>;
} }
if (isSelected) { if (isSelected) {
return <span className={cx([style.timeUntil, style.live])}>LIVE</span>; return <span className={style.timeUntil}>LIVE</span>;
} }
return ( return (
@@ -195,7 +186,7 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return ( return (
<span className={cx([style.timeUntil, isDue && style.due])} data-testid='time-until'> <span className={style.timeUntil} data-testid='time-until'>
{timeUntilString} {timeUntilString}
</span> </span>
); );
@@ -1,33 +1,13 @@
.group { .group {
width: 100%; width: 100%;
min-height: 2.5rem; padding: 0.25rem 0.5rem;
padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350; background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
letter-spacing: 0;
} }
.title { // tablet
flex: 1 1 auto; @media (min-width: $min-tablet) {
min-width: 0; .group {
overflow: hidden; padding: 0.25rem 1rem;
text-overflow: ellipsis; }
white-space: nowrap;
}
.meta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
color: color-mix(in srgb, currentColor 60%, transparent);
font-size: 0.875rem;
font-weight: 500;
} }
@@ -1,29 +1,12 @@
import { CSSProperties, memo } from 'react'; import { memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss'; import style from './OperatorGroup.module.scss';
interface OperatorGroup { interface OperatorGroup {
title: string; title: string;
colour: string;
count: number;
duration: number;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) { function OperatorGroup({ title }: OperatorGroup) {
const groupColour = colour || '#929292'; return <div className={style.group}>{title}</div>;
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
<span>{formatDuration(duration)}</span>
</span>
</div>
);
} }
@@ -3,94 +3,58 @@
height: 100%; height: 100%;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 1rem 1.5rem; padding: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: auto;
} }
.shortcutSection { .shortcutSection {
flex: 1; flex: 1;
width: min(100%, 48rem); margin-top: 15vh;
margin-top: clamp(1.5rem, 8vh, 5rem);
margin-inline: auto; margin-inline: auto;
gap: 1rem;
} }
.shortcuts { .shortcuts {
display: grid; font-size: calc(1rem - 3px);
gap: 0.875rem; border-collapse: separate;
margin-top: 0.875rem; border-spacing: 4rem 0;
}
.shortcutGroup { tr {
h3 { white-space: nowrap;
margin: 0 0 0.375rem; td:nth-child(odd) {
color: $ui-white; text-align: left;
font-size: calc(1rem - 3px); }
font-weight: 600; td:nth-child(even) {
text-transform: uppercase; text-align: right;
}
} }
} }
.shortcutList { .spacer {
display: grid; height: 1rem;
gap: 0.25rem;
}
.shortcutRow {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.shortcutLabel {
min-width: 0;
line-height: 1.2;
}
.shortcutKeys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.keyCombo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: $gray-500;
font-size: calc(1rem - 5px);
} }
.prompt { .prompt {
margin-left: 4rem;
}
.divider {
display: inline-block;
text-align: center;
width: 1em;
}
.kbd {
font-family: monospace;
white-space: nowrap;
font-size: calc(1rem - 2px);
padding: 0.125rem 0.5rem;
background-color: $gray-1200;
color: $ui-white; color: $ui-white;
} border-radius: 2px;
font-weight: 400;
@media (max-width: 680px) { box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
.entryEditor {
padding: 0.75rem 1rem;
}
.shortcutSection {
margin-top: 1rem;
}
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
} }
@@ -1,7 +1,6 @@
import { PropsWithChildren, memo } from 'react'; import { PropsWithChildren, memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Kbd from '../../../common/components/kbd/Kbd';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss'; import style from './EventEditorEmpty.module.scss';
@@ -13,124 +12,216 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}> <div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title> <Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<div className={style.shortcuts}> <table className={style.shortcuts}>
<ShortcutGroup title='Search'> <tbody>
<Shortcut label='Find in rundown'> <tr>
<Combo keys={[deviceMod, 'F']} /> <td>Find in rundown</td>
</Shortcut> <td>
<Shortcut label='Open settings'> <Kbd>{deviceMod}</Kbd>
<Combo keys={[deviceMod, ',']} /> <AuxKey>+</AuxKey>
</Shortcut> <Kbd>F</Kbd>
</ShortcutGroup> </td>
</tr>
<ShortcutGroup title='Navigation'> <tr>
<Shortcut label='Select entry'> <td>Open Settings</td>
<Combo keys={[deviceAlt, '↑']} /> <td>
<Separator /> <Kbd>{deviceMod}</Kbd>
<Combo keys={[deviceAlt, '↓']} /> <AuxKey>+</AuxKey>
</Shortcut> <Kbd>,</Kbd>
<Shortcut label='Select group'> </td>
<Combo keys={[deviceAlt, 'Shift', '↑']} /> </tr>
<Separator /> <tr className={style.spacer} />
<Combo keys={[deviceAlt, 'Shift', '↓']} /> <tr>
</Shortcut> <td>Select entry</td>
<Shortcut label='Jump to top / bottom'> <td>
<Combo keys={['Home']} /> <Kbd>{deviceAlt}</Kbd>
<Separator /> <AuxKey>+</AuxKey>
<Combo keys={['End']} /> <Kbd></Kbd>
</Shortcut> <AuxKey>/</AuxKey>
<Shortcut label='Page up / down'> <Kbd></Kbd>
<Combo keys={['PgUp']} /> </td>
<Separator /> </tr>
<Combo keys={['PgDn']} /> <tr>
</Shortcut> <td>Select group</td>
<Shortcut label='Deselect entry'> <td>
<Combo keys={['Esc']} /> <Kbd>{deviceAlt}</Kbd>
</Shortcut> <AuxKey>+</AuxKey>
</ShortcutGroup> <Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<ShortcutGroup title='Editing'> <Kbd></Kbd>
<Shortcut label='Reorder selected entry'> <AuxKey>/</AuxKey>
<Combo keys={[deviceAlt, deviceMod, '↑']} /> <Kbd></Kbd>
<Separator /> </td>
<Combo keys={[deviceAlt, deviceMod, '↓']} /> </tr>
</Shortcut> <tr>
<Shortcut label='Copy selected entry'> <td>Jump to top / bottom</td>
<Combo keys={[deviceMod, 'C']} /> <td>
</Shortcut> <Kbd>Home</Kbd>
<Shortcut label='Cut selected entry'> <AuxKey>/</AuxKey>
<Combo keys={[deviceMod, 'X']} /> <Kbd>End</Kbd>
</Shortcut> </td>
<Shortcut label='Paste below'> </tr>
<Combo keys={[deviceMod, 'V']} /> <tr>
</Shortcut> <td>Page up / down</td>
<Shortcut label='Paste above'> <td>
<Combo keys={[deviceMod, 'Shift', 'V']} /> <Kbd>PgUp</Kbd>
</Shortcut> <AuxKey>/</AuxKey>
<Shortcut label='Clone selected entry'> <Kbd>PgDn</Kbd>
<Combo keys={[deviceMod, 'D']} /> </td>
</Shortcut> </tr>
<Shortcut label='Delete selected entry'> <tr>
<Combo keys={[deviceAlt, 'Backspace']} /> <td>Deselect entry</td>
</Shortcut> <td>
</ShortcutGroup> <Kbd>Esc</Kbd>
</td>
<ShortcutGroup title='Insert'> </tr>
<Shortcut label='Add event below / above'> <tr className={style.spacer} />
<Combo keys={[deviceAlt, 'E']} /> <tr>
<Separator /> <td>Reorder selected entry</td>
<Combo keys={[deviceAlt, 'Shift', 'E']} /> <td>
</Shortcut> <Kbd>{deviceAlt}</Kbd>
<Shortcut label='Add group below / above'> <AuxKey>+</AuxKey>
<Combo keys={[deviceAlt, 'G']} /> <Kbd>{deviceMod}</Kbd>
<Separator /> <AuxKey>+</AuxKey>
<Combo keys={[deviceAlt, 'Shift', 'G']} /> <Kbd></Kbd>
</Shortcut> <AuxKey>/</AuxKey>
<Shortcut label='Add milestone below / above'> <Kbd></Kbd>
<Combo keys={[deviceAlt, 'M']} /> </td>
<Separator /> </tr>
<Combo keys={[deviceAlt, 'Shift', 'M']} /> <tr>
</Shortcut> <td>Copy selected entry</td>
<Shortcut label='Add delay below / above'> <td>
<Combo keys={[deviceAlt, 'D']} /> <Kbd>{deviceMod}</Kbd>
<Separator /> <AuxKey>+</AuxKey>
<Combo keys={[deviceAlt, 'Shift', 'D']} /> <Kbd>C</Kbd>
</Shortcut> </td>
</ShortcutGroup> </tr>
</div> <tr>
<td>Cut selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr>
<td>Paste above</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Paste below</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Clone selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Add event below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add event above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add group below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add group above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add milestone below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add milestone above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add delay below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Add delay above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
); );
} }
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) { function AuxKey({ children }: PropsWithChildren) {
return ( return <span className={style.divider}>{children}</span>;
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
} }
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) { function Kbd({ children }: PropsWithChildren) {
return ( return <span className={style.kbd}>{children}</span>;
<div className={style.shortcutRow}>
<span className={style.shortcutLabel}>{label}</span>
<span className={style.shortcutKeys}>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.keyCombo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className={style.separator}>/</span>;
} }
@@ -18,7 +18,7 @@ import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog'; import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -156,7 +156,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`, shortcut: `${deviceMod}+Del`,
onClick: () => { onClick: () => {
clearSelectedEvents(); clearSelectedEvents();
deleteEntry(Array.from(selectedEvents)); deleteEntry(Array.from(selectedEvents));
@@ -202,7 +202,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`, shortcut: `${deviceMod}+Del`,
onClick: () => { onClick: () => {
deleteEntry([eventId]); deleteEntry([eventId]);
unselect(eventId); unselect(eventId);
@@ -3,5 +3,7 @@
width: 0; width: 0;
border-radius: 1px 0 0 1px; border-radius: 1px 0 0 1px;
transition: 1s linear;
transition-property: width;
background-color: $gray-200; background-color: $gray-200;
} }
@@ -1,12 +1,12 @@
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress';
import style from './RundownEventProgressBar.module.scss'; import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() { export default function RundownEventProgressBar() {
const timer = useTimer(); const timer = useTimer();
const progress = useAnimatedProgress(timer.current, timer.duration); const progress = getProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />; return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
} }
@@ -82,17 +82,6 @@
margin-right: 0.25rem; margin-right: 0.25rem;
color: $ui-white; color: $ui-white;
} }
.duration {
display: flex;
align-items: center;
gap: 0.25rem;
}
.lockIcon {
opacity: 0.6;
}
.over { .over {
color: $playback-over; color: $playback-over;
.strike { .strike {
@@ -8,7 +8,6 @@ import {
IoChevronUp, IoChevronUp,
IoDuplicateOutline, IoDuplicateOutline,
IoFolderOpenOutline, IoFolderOpenOutline,
IoLockClosed,
IoReorderTwo, IoReorderTwo,
IoTrash, IoTrash,
} from 'react-icons/io5'; } from 'react-icons/io5';
@@ -18,7 +17,7 @@ import Tag from '../../../common/components/tag/Tag';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time'; import { formatDuration, formatTime } from '../../../common/utils/time';
@@ -66,7 +65,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
type: 'item', type: 'item',
label: 'Delete Group', label: 'Delete Group',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`, shortcut: `${deviceMod}+Del`,
onClick: () => deleteEntry([data.id]), onClick: () => deleteEntry([data.id]),
}, },
]); ]);
@@ -177,17 +176,14 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div className={style.metaLabel}>Duration</div> <div className={style.metaLabel}>Duration</div>
<div className={style.duration}> {planOffset === null ? (
{planOffset === null ? ( <div>{formatDuration(data.duration)}</div>
formatDuration(data.duration) ) : (
) : ( <div className={cx([planOffsetLabel && style[planOffsetLabel]])}>
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}> <span className={style.strike}>{formatDuration(data.duration)}</span>
<span className={style.strike}>{formatDuration(data.duration)}</span> <Tag className={style.offsetLabel}>{planOffset}</Tag>
<Tag className={style.offsetLabel}>{planOffset}</Tag> </div>
</span> )}
)}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -9,7 +9,7 @@ import useReactiveTextInput from '../../../common/components/input/text-input/us
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt } from '../../../common/utils/deviceUtils'; import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -38,7 +38,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`, shortcut: `${deviceMod}+Del`,
onClick: () => deleteEntry([entryId]), onClick: () => deleteEntry([entryId]),
}, },
]); ]);
@@ -1,6 +1,6 @@
import { OntimeView, URLPreset } from 'ontime-types'; import { OntimeView, URLPreset } from 'ontime-types';
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import { useCallback, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { FieldErrors, useForm } from 'react-hook-form'; import { FieldErrors, useForm } from 'react-hook-form';
import { generateUrl } from '../../common/api/session'; import { generateUrl } from '../../common/api/session';
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
import { isUrlSafe } from '../../common/utils/regex'; import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals'; import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
import CuesheetLinkOptions, { CuesheetPermissionValues } from './composite/CuesheetLinkOptions'; import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
import style from './GenerateLinkForm.module.scss'; import style from './GenerateLinkForm.module.scss';
@@ -53,26 +53,12 @@ type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) { export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
const [formState, setFormState] = useState<GenerateLinkState>('pending'); const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(''); const [url, setUrl] = useState(serverURL);
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>({ const cuesheetReadRef = useRef<HTMLInputElement>(null);
read: 'full', const cuesheetWriteRef = useRef<HTMLInputElement>(null);
write: 'full',
});
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`); const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
const { addPreset, updatePreset } = useUpdateUrlPreset(); const { addPreset } = useUpdateUrlPreset();
// Tracks the alias we already created this session so re-generating updates rather than duplicates it
const createdAlias = useRef<string | null>(null);
/**
* Permissions live outside react-hook-form, so we reset a successful state manually
* whenever they change - this re-arms the "Create share link" button as the previous
* link no longer reflects the selected permissions.
*/
const handlePermissionsChange = useCallback((permissions: CuesheetPermissionValues) => {
setCuesheetPermissions(permissions);
setFormState((current) => (current === 'success' ? 'pending' : current));
}, []);
const { const {
handleSubmit, handleSubmit,
@@ -104,20 +90,16 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
if (options.read === '-') { if (options.read === '-') {
throw new Error('Cannot create a share with no read permissions'); throw new Error('Cannot create a share with no read permissions');
} }
const payload = { const presets = await addPreset({
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
enabled: true, enabled: true,
alias, alias,
search: '', search: '',
displayInNav: false,
options: { options: {
read: options.read, read: options.read,
write: options.write, write: options.write,
}, },
} as const; });
// Re-generating with the same name updates the existing preset instead of failing on a duplicate alias
const presets = createdAlias.current === alias ? await updatePreset(alias, payload) : await addPreset(payload);
createdAlias.current = alias;
return presets.find((preset) => preset.alias === alias); return presets.find((preset) => preset.alias === alias);
}; };
@@ -126,8 +108,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
setFormState('loading'); setFormState('loading');
if (options.path === OntimeView.Cuesheet) { if (options.path === OntimeView.Cuesheet) {
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, { const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
read: cuesheetPermissions.read, read: cuesheetReadRef.current?.value ?? 'full',
write: cuesheetPermissions.write, write: cuesheetWriteRef.current?.value ?? 'full',
}); });
if (!urlPreset) { if (!urlPreset) {
@@ -175,7 +157,6 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
} }
}; };
const noReadAccess = watch('path') === OntimeView.Cuesheet && cuesheetPermissions.read === '-';
const canSubmit = isDirty || formState !== 'success'; const canSubmit = isDirty || formState !== 'success';
return ( return (
@@ -239,7 +220,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
})} })}
/> />
</Panel.ListItem> </Panel.ListItem>
<CuesheetLinkOptions onChange={handlePermissionsChange} /> <CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
</> </>
)} )}
@@ -287,29 +268,18 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
</Panel.ListGroup> </Panel.ListGroup>
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end' className={style.end}> <Panel.InlineElements align='end' className={style.end}>
<Button <Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
type='submit'
variant={canSubmit ? 'primary' : 'subtle'}
loading={formState === 'loading'}
disabled={noReadAccess}
>
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'} {canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
<Panel.Section className={style.column}> <Panel.Section className={style.column}>
<Panel.Description>Share this link</Panel.Description> <Panel.Description>Share this link</Panel.Description>
{url ? ( <QRCode size={172} value={url} />
<> <div className={style.copiableLink} data-testid='copy-link'>
<QRCode size={172} value={url} /> {url}
<div className={style.copiableLink} data-testid='copy-link'> </div>
{url} <CopyTag copyValue={url}>Copy link</CopyTag>
</div>
<CopyTag copyValue={url}>Copy link</CopyTag>
</>
) : (
<Panel.Description>Your link will appear here once you create it.</Panel.Description>
)}
</Panel.Section> </Panel.Section>
</div> </div>
</form> </form>
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
import useInfo from '../../common/hooks-query/useInfo'; import useInfo from '../../common/hooks-query/useInfo';
import useUrlPresets from '../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../common/hooks-query/useUrlPresets';
import { hostToBaseUrl } from '../../common/utils/linkUtils';
import GenerateLinkForm from './GenerateLinkForm'; import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps { interface GenerateLinkFormExportProps {
@@ -22,7 +21,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
*/ */
const hostOptions = useMemo(() => { const hostOptions = useMemo(() => {
return infoData.networkInterfaces.map((nif) => ({ return infoData.networkInterfaces.map((nif) => ({
value: hostToBaseUrl(nif.address, infoData.serverPort), value: `http://${nif.address}:${infoData.serverPort}`,
label: `${nif.name} - ${nif.address}`, label: `${nif.name} - ${nif.address}`,
})); }));
}, [infoData.networkInterfaces, infoData.serverPort]); }, [infoData.networkInterfaces, infoData.serverPort]);
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Fragment, RefObject, useMemo, useState } from 'react';
import RadioGroup from '../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../common/components/radio-group/RadioGroup';
import Switch from '../../../common/components/switch/Switch'; import Switch from '../../../common/components/switch/Switch';
@@ -10,141 +10,118 @@ import style from './CuesheetLinkOptions.module.scss';
type AccessMode = 'full' | 'custom'; type AccessMode = 'full' | 'custom';
export interface CuesheetPermissionValues {
read: string;
write: string;
}
interface CuesheetLinkOptionsProps { interface CuesheetLinkOptionsProps {
/** Existing read permission to seed the form with ('full' | '-' | comma separated keys) */ readRef?: RefObject<HTMLInputElement | null>;
initialRead?: string; writeRef?: RefObject<HTMLInputElement | null>;
/** Existing write permission to seed the form with ('full' | '-' | comma separated keys) */
initialWrite?: string;
/** Notifies the parent whenever the resolved read/write permissions change */
onChange: (permissions: CuesheetPermissionValues) => void;
} }
/** A null result means "full or unset" - there is no explicit per-column selection to seed */ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
function parseKeys(permission: string | undefined): Set<string> | null {
if (permission == null || permission === 'full') {
return null;
}
if (permission === '-') {
return new Set();
}
return new Set(permission.split(','));
}
function modeFromPermission(permission: string | undefined): AccessMode {
return permission == null || permission === 'full' ? 'full' : 'custom';
}
export default function CuesheetLinkOptions({ initialRead, initialWrite, onChange }: CuesheetLinkOptionsProps) {
const { data } = useCustomFields(); const { data } = useCustomFields();
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]); const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
// Parsed seed values - stable for the lifetime of a given preset const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]); const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
const [readPermissions, setReadPermissions] = useState<AccessMode>(() => modeFromPermission(initialRead)); const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
const [writePermissions, setWritePermissions] = useState<AccessMode>(() => modeFromPermission(initialWrite)); const initialState: Record<string, boolean> = {};
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
// Default for a column we have not seen yet: honour the seed in custom mode, otherwise grant access initialState[column.value] = true;
const defaultRead = useCallback(
(key: string) => (initialReadKeys ? initialReadKeys.has(key) : true),
[initialReadKeys],
);
const defaultWrite = useCallback(
(key: string) => (initialWriteKeys ? initialWriteKeys.has(key) : true),
[initialWriteKeys],
);
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>({});
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>({});
// Custom fields load asynchronously, so reconcile the switch maps whenever the column list grows.
// Newly seen columns are seeded from the initial values (or default to on for a fresh link).
useEffect(() => {
setReadSwitches((prev) => {
const next = { ...prev };
for (const column of allColumns) {
if (!(column.value in next)) next[column.value] = defaultRead(column.value);
}
return next;
}); });
setWriteSwitches((prev) => { return initialState;
const next = { ...prev }; });
for (const column of allColumns) {
if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
}
return next;
});
}, [allColumns, defaultRead, defaultWrite]);
const isReadOn = (key: string) => readSwitches[key] ?? defaultRead(key); const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
const isWriteOn = (key: string) => writeSwitches[key] ?? defaultWrite(key); const initialState: Record<string, boolean> = {};
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
initialState[column.value] = true;
});
return initialState;
});
const handleReadModeChange = (value: AccessMode) => { const handleReadModeChange = (value: AccessMode) => {
setReadPermissions(value); setReadPermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
Object.keys(updatedReadSwitches).forEach((key) => {
updatedReadSwitches[key] = true;
});
return updatedReadSwitches;
});
}; };
const handleWriteModeChange = (value: AccessMode) => { const handleWriteModeChange = (value: AccessMode) => {
setWritePermissions(value);
// Full write implies full read
if (value === 'full') { if (value === 'full') {
setReadPermissions('full'); setReadPermissions('full');
} }
setWritePermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches };
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
updatedReadSwitches[column.value] = true;
updatedWriteSwitches[column.value] = true;
});
return updatedWriteSwitches;
});
return updatedReadSwitches;
});
}; };
const handleReadSwitch = (key: string, value: boolean) => { const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
setReadSwitches((prev) => ({ ...prev, [key]: value })); if (type === 'read') {
// A column the recipient cannot read cannot be written either setReadSwitches((prevReadSwitches) => {
if (!value) { const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
setWriteSwitches((prev) => ({ ...prev, [key]: false })); return updatedReadSwitches;
});
} else {
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
return updatedWriteSwitches;
});
} }
}; };
const handleWriteSwitch = (key: string, value: boolean) => { const getReadPermissions = () => {
setWriteSwitches((prev) => ({ ...prev, [key]: value }));
// Granting write access requires read access
if (value) {
setReadSwitches((prev) => ({ ...prev, [key]: true }));
}
};
const resolvedRead = useMemo(() => {
if (readPermissions === 'full' || writePermissions === 'full') { if (readPermissions === 'full' || writePermissions === 'full') {
return 'full'; return 'full';
} }
const keys = allColumns.filter((column) => isReadOn(column.value)).map((column) => column.value);
return keys.length ? keys.join(',') : '-';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readPermissions, writePermissions, readSwitches, allColumns]);
const resolvedWrite = useMemo(() => { return Object.entries(readSwitches)
.filter(([_, value]) => value)
.map(([key]) => key)
.join(',');
};
const getWritePermissions = () => {
if (writePermissions === 'full') { if (writePermissions === 'full') {
return 'full'; return 'full';
} }
const keys = allColumns.filter((column) => isWriteOn(column.value)).map((column) => column.value);
return keys.length ? keys.join(',') : '-';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writePermissions, writeSwitches, allColumns]);
// Notify the parent of the resolved permissions. onChange is expected to be stable. return Object.entries(writeSwitches)
useEffect(() => { .filter(([_, value]) => value)
onChange({ read: resolvedRead, write: resolvedWrite }); .map(([key]) => key)
// eslint-disable-next-line react-hooks/exhaustive-deps .join(',');
}, [resolvedRead, resolvedWrite]); };
const noReadAccess = resolvedRead === '-';
return ( return (
<Panel.Indent> <Panel.Indent>
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
<div> <div>
<Panel.Field title='Access mode' description='Which parts of the data the link gives access to' /> <Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
<div> <div>
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
<RadioGroup <RadioGroup
value={readPermissions} value={readPermissions}
onValueChange={handleReadModeChange} onValueChange={handleReadModeChange}
@@ -155,18 +132,8 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
{ value: 'custom', label: 'Custom read' }, { value: 'custom', label: 'Custom read' },
]} ]}
/> />
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
</div> </div>
</div> </div>
{noReadAccess && <Panel.Error>Links must contain at least one readable column.</Panel.Error>}
<div className={style.twoCols}> <div className={style.twoCols}>
<div className={style.grid}> <div className={style.grid}>
<Panel.Description>Ontime columns</Panel.Description> <Panel.Description>Ontime columns</Panel.Description>
@@ -176,14 +143,14 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
<Fragment key={column.value}> <Fragment key={column.value}>
<div>{column.label}</div> <div>{column.label}</div>
<Switch <Switch
checked={isReadOn(column.value)} checked={Boolean(readSwitches[column.value])}
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)} onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
disabled={readPermissions === 'full' || writePermissions === 'full'} disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`} data-testid={`read-${column.value}`}
/> />
<Switch <Switch
checked={isWriteOn(column.value)} checked={Boolean(writeSwitches[column.value])}
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)} onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
disabled={writePermissions === 'full'} disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`} data-testid={`write-${column.value}`}
/> />
@@ -197,16 +164,16 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
<Panel.Description>Write</Panel.Description> <Panel.Description>Write</Panel.Description>
{customFieldColumns.map((column) => ( {customFieldColumns.map((column) => (
<Fragment key={column.value}> <Fragment key={column.value}>
<div>{column.label}</div> {column.label}
<Switch <Switch
checked={isReadOn(column.value)} checked={Boolean(readSwitches[column.value])}
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)} onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
disabled={readPermissions === 'full' || writePermissions === 'full'} disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`} data-testid={`read-${column.value}`}
/> />
<Switch <Switch
checked={isWriteOn(column.value)} checked={Boolean(writeSwitches[column.value])}
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)} onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
disabled={writePermissions === 'full'} disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`} data-testid={`write-${column.value}`}
/> />
@@ -18,7 +18,6 @@ export const langDe: TranslationObject = {
'common.no_data': 'Keine Daten', 'common.no_data': 'Keine Daten',
'countdown.ended': 'Veranstaltung endete um', 'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft', 'countdown.running': 'Veranstaltung läuft',
'countdown.group_running': 'Veranstaltung in Gruppe läuft',
'countdown.loaded': 'Veranstaltung geladen', 'countdown.loaded': 'Veranstaltung geladen',
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen', 'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
'countdown.to_start': 'Zeit bis zum Start', 'countdown.to_start': 'Zeit bis zum Start',
@@ -29,7 +28,6 @@ export const langDe: TranslationObject = {
'timeline.done': 'Beendet', 'timeline.done': 'Beendet',
'timeline.due': 'fällig', 'timeline.due': 'fällig',
'timeline.followedby': 'Gefolgt von', 'timeline.followedby': 'Gefolgt von',
'timeline.standby': 'Bereitschaft',
'project.title': 'Titel', 'project.title': 'Titel',
'project.description': 'Beschreibung', 'project.description': 'Beschreibung',
'project.info': 'Projektinfo', 'project.info': 'Projektinfo',
@@ -18,7 +18,6 @@ export const langEs: TranslationObject = {
'common.no_data': 'Sin datos', 'common.no_data': 'Sin datos',
'countdown.ended': 'Evento finalizado a las', 'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso', 'countdown.running': 'Evento en curso',
'countdown.group_running': 'Evento en grupo en curso',
'countdown.loaded': 'Evento está cargado', //TODO: check translation 'countdown.loaded': 'Evento está cargado', //TODO: check translation
'countdown.select_event': 'Seleccionar un evento para seguir', 'countdown.select_event': 'Seleccionar un evento para seguir',
'countdown.to_start': 'Tiempo para comenzar', 'countdown.to_start': 'Tiempo para comenzar',
@@ -29,7 +28,6 @@ export const langEs: TranslationObject = {
'timeline.done': 'Terminado', 'timeline.done': 'Terminado',
'timeline.due': 'pendiente', 'timeline.due': 'pendiente',
'timeline.followedby': 'Seguido por', 'timeline.followedby': 'Seguido por',
'timeline.standby': 'En espera',
'project.title': 'Título', 'project.title': 'Título',
'project.description': 'Descripción', 'project.description': 'Descripción',
'project.info': 'Información del proyecto', 'project.info': 'Información del proyecto',
@@ -18,7 +18,6 @@ export const langFr: TranslationObject = {
'common.no_data': 'Aucune donnée', 'common.no_data': 'Aucune donnée',
'countdown.ended': 'Évènement terminé à', 'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours', 'countdown.running': 'Évènement en cours',
'countdown.group_running': 'Évènement du groupe en cours',
'countdown.loaded': 'Évènement chargé', 'countdown.loaded': 'Évènement chargé',
'countdown.select_event': 'Sélectionnez un évènement à suivre', 'countdown.select_event': 'Sélectionnez un évènement à suivre',
'countdown.to_start': 'Évènement commence dans', 'countdown.to_start': 'Évènement commence dans',
@@ -29,7 +28,6 @@ export const langFr: TranslationObject = {
'timeline.done': 'Terminé', 'timeline.done': 'Terminé',
'timeline.due': 'dû', 'timeline.due': 'dû',
'timeline.followedby': 'Suivi de', 'timeline.followedby': 'Suivi de',
'timeline.standby': 'En attente',
'project.title': 'Titre', 'project.title': 'Titre',
'project.description': 'Description', 'project.description': 'Description',
'project.info': 'Informations du projet', 'project.info': 'Informations du projet',
@@ -18,7 +18,6 @@ export const langIt: TranslationObject = {
'common.no_data': 'Nessun dato disponibile', 'common.no_data': 'Nessun dato disponibile',
'countdown.ended': 'Evento finito alle', 'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso', 'countdown.running': 'Evento in corso',
'countdown.group_running': 'Evento nel gruppo in corso',
'countdown.loaded': 'Evento caricato', //TODO: check translation 'countdown.loaded': 'Evento caricato', //TODO: check translation
'countdown.select_event': 'Seleziona un evento da seguire', 'countdown.select_event': 'Seleziona un evento da seguire',
'countdown.to_start': 'Tempo alla partenza', 'countdown.to_start': 'Tempo alla partenza',
@@ -29,7 +28,6 @@ export const langIt: TranslationObject = {
'timeline.done': 'Terminato', 'timeline.done': 'Terminato',
'timeline.due': 'previsto', 'timeline.due': 'previsto',
'timeline.followedby': 'Seguito da', 'timeline.followedby': 'Seguito da',
'timeline.standby': 'In attesa',
'project.title': 'Titolo', 'project.title': 'Titolo',
'project.description': 'Descrizione', 'project.description': 'Descrizione',
'project.info': 'Informazioni sul progetto', 'project.info': 'Informazioni sul progetto',
@@ -18,7 +18,6 @@ export const langPt: TranslationObject = {
'common.no_data': 'Sem dados', 'common.no_data': 'Sem dados',
'countdown.ended': 'Evento encerrado às', 'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento', 'countdown.running': 'Evento em andamento',
'countdown.group_running': 'Evento em grupo em andamento',
'countdown.loaded': 'Evento carregado', //TODO: check translation 'countdown.loaded': 'Evento carregado', //TODO: check translation
'countdown.select_event': 'Selecione um evento para acompanhar', 'countdown.select_event': 'Selecione um evento para acompanhar',
'countdown.to_start': 'Tempo para iniciar', 'countdown.to_start': 'Tempo para iniciar',
@@ -29,7 +28,6 @@ export const langPt: TranslationObject = {
'timeline.done': 'Concluído', 'timeline.done': 'Concluído',
'timeline.due': 'Pendente', 'timeline.due': 'Pendente',
'timeline.followedby': 'Seguido por', 'timeline.followedby': 'Seguido por',
'timeline.standby': 'Em espera',
'project.title': 'Título', 'project.title': 'Título',
'project.description': 'Descrição', 'project.description': 'Descrição',
'project.info': 'Informações do projeto', 'project.info': 'Informações do projeto',
@@ -7,7 +7,6 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@@ -141,23 +140,25 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
return () => clearInterval(paginator.current); return () => clearInterval(paginator.current);
}, [cycleInterval, numPages, stopCycle, visiblePage]); }, [cycleInterval, numPages, stopCycle, visiblePage]);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
// we want to show the event after the current // we want to show the event after the current
const viewEvents = useMemo(() => { const viewEvents = events.slice(selectedEventIndex + 1);
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId); selectedEventIndex = 0;
return (events as ExtendedEntry<OntimeEvent>[]).slice(selectedEventIndex + 1);
}, [events, selectedEventId]);
const value = useMemo(() => { return (
return { <ScheduleContext
events: viewEvents, value={{
selectedEventId, events: viewEvents as ExtendedEntry<OntimeEvent>[],
numPages, selectedEventId,
visiblePage, numPages,
containerRef, visiblePage,
}; containerRef,
}, [viewEvents, selectedEventId, numPages, visiblePage, containerRef]); }}
>
return <ScheduleContext value={value}>{children}</ScheduleContext>; {children}
</ScheduleContext>
);
}; };
export const useSchedule = () => { export const useSchedule = () => {
+3 -106
View File
@@ -59,7 +59,6 @@ $item-height: 3.5rem;
right: $view-inline-padding; right: $view-inline-padding;
display: flex; display: flex;
align-items: center;
justify-content: end; justify-content: end;
gap: 2rem; gap: 2rem;
@@ -73,27 +72,14 @@ $item-height: 3.5rem;
} }
/* ========================= LIST ========================*/ /* ========================= LIST ========================*/
.empty-state { .empty-container {
min-height: min(56vh, 36rem);
padding-top: clamp(4rem, 14vh, 9rem);
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
button { button {
margin-top: 1.75rem; margin-top: 2rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
} }
} }
@@ -102,7 +88,7 @@ $item-height: 3.5rem;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom))); padding-bottom: 60vh;
} }
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
@@ -118,7 +104,6 @@ $item-height: 3.5rem;
column-gap: 1rem; column-gap: 1rem;
background-color: var(--card-background-color-override, $viewer-card-bg-color); background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding-right: 1rem; padding-right: 1rem;
border: 1px solid transparent;
border-radius: $element-border-radius; border-radius: $element-border-radius;
&:hover { &:hover {
@@ -130,41 +115,18 @@ $item-height: 3.5rem;
.sub--selected { .sub--selected {
background: $blue-700; background: $blue-700;
box-shadow: inset 0 0 0 1px $blue-500;
} }
.sub--live { .sub--live {
background-color: $active-green; background-color: $active-green;
box-shadow: inset 0 0 0 1px color-mix(in srgb, $active-green 75%, $ui-white);
} }
.sub--armed { .sub--armed {
background-color: $gray-1325; background-color: $gray-1325;
} }
.sub--in-group {
position: relative;
margin-left: 1rem;
&::before {
content: '';
position: absolute;
left: -0.5rem;
top: -0.25rem;
bottom: -0.25rem;
width: 2px;
border-radius: 1px;
background: var(--group-color, transparent);
}
.sub__binder {
box-shadow: inset 3px 0 0 var(--group-color, transparent);
}
}
.sub__binder { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
border-radius: $element-border-radius 0 0 $element-border-radius;
grid-area: binder; grid-area: binder;
} }
@@ -176,8 +138,6 @@ $item-height: 3.5rem;
gap: 0.25em; gap: 0.25em;
font-size: $timer-label-size; font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
white-space: nowrap;
font-variant-numeric: tabular-nums;
} }
.sub__schedule--delayed { .sub__schedule--delayed {
@@ -196,73 +156,22 @@ $item-height: 3.5rem;
color: $playback-under; color: $playback-under;
} }
.sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color);
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
box-shadow: none;
}
.sub__title {
font-weight: 700;
}
.sub__schedule,
.sub__status,
.sub__label {
color: var(--secondary-color-override, $viewer-secondary-color);
}
}
.sub--group.sub--selected {
box-shadow: inset 0 0 0 1px $blue-500;
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 22%, transparent), transparent 42%),
$blue-700;
.sub__schedule,
.sub__status,
.sub__label,
.sub__eyebrow {
color: $ui-white;
}
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
min-width: 0;
font-size: $title-font-size; font-size: $title-font-size;
line-height: 1.1em; line-height: 1.1em;
overflow-wrap: anywhere;
}
.sub__eyebrow {
display: block;
margin-bottom: 0.15rem;
color: var(--secondary-color-override, $viewer-secondary-color);
font-size: $timer-label-size;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
} }
.sub__secondary { .sub__secondary {
grid-area: secondary; grid-area: secondary;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
min-width: 0;
font-size: $base-font-size; font-size: $base-font-size;
line-height: 1.1em; line-height: 1.1em;
color: var(--label-color-override, $viewer-label-color);
// allow multi-line text but trim before // allow multi-line text but trim before
white-space: pre-line; white-space: pre-line;
overflow-wrap: anywhere;
} }
.sub__status { .sub__status {
@@ -272,7 +181,6 @@ $item-height: 3.5rem;
font-size: $timer-label-size; font-size: $timer-label-size;
text-align: right; text-align: right;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap;
} }
.sub__label { .sub__label {
@@ -282,7 +190,6 @@ $item-height: 3.5rem;
font-size: $timer-label-size; font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-align: right; text-align: right;
white-space: nowrap;
} }
.sub__timer { .sub__timer {
@@ -292,16 +199,6 @@ $item-height: 3.5rem;
line-height: 1.1em; line-height: 1.1em;
font-weight: 600; font-weight: 600;
text-align: right; text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.selection-count {
color: var(--label-color-override, $viewer-label-color);
font-size: $timer-label-size;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
} }
/* ====================== MODIFIERS ======================*/ /* ====================== MODIFIERS ======================*/
+18 -42
View File
@@ -1,14 +1,4 @@
import { import { EntryId, OntimeEvent, OntimeView, PlayableEvent, isOntimeEvent, isPlayableEvent } from 'ontime-types';
EntryId,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeView,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
@@ -25,7 +15,7 @@ import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader'; import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getCountdownOptions, useCountdownOptions } from './countdown.options'; import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions, resolveSubscriptionTarget } from './countdown.utils'; import { getOrderedSubscriptions } from './countdown.utils';
import CountdownSelect from './CountdownSelect'; import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions'; import CountdownSubscriptions from './CountdownSubscriptions';
import SingleEventCountdown from './SingleEventCountdown'; import SingleEventCountdown from './SingleEventCountdown';
@@ -55,19 +45,13 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
// gather subscribable items: playable events and groups that contain at least one playable child // gather rundown data
const candidates = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent | OntimeGroup> => { const playableEvents = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent> => {
if (isOntimeEvent(entry)) { return isOntimeEvent(entry) && isPlayableEvent(entry);
return isPlayableEvent(entry);
}
if (isOntimeGroup(entry)) {
return rundownData.some((item) => isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id);
}
return false;
}); });
// gather presentation data // gather presentation data
const hasEvents = candidates.length > 0; const hasEvents = playableEvents.length > 0;
// gather option data // gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -85,20 +69,15 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
<CountdownClock /> <CountdownClock />
</div> </div>
{!hasEvents && ( {!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
</div>
)}
{hasEvents && editMode && ( {hasEvents && editMode && (
<CountdownSelect events={candidates} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} /> <CountdownSelect events={playableEvents} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
)} )}
{hasEvents && !editMode && ( {hasEvents && !editMode && (
<CountdownContents <CountdownContents
candidates={candidates} playableEvents={playableEvents}
rundownData={rundownData}
subscriptions={subscriptions} subscriptions={subscriptions}
goToEditMode={() => setEditMode(true)} goToEditMode={() => setEditMode(true)}
/> />
@@ -108,20 +87,19 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
} }
interface CountdownContentsProps { interface CountdownContentsProps {
candidates: ExtendedEntry<OntimeEvent | OntimeGroup>[]; playableEvents: ExtendedEntry<OntimeEvent>[];
rundownData: ExtendedEntry<OntimeEntry>[];
subscriptions: EntryId[]; subscriptions: EntryId[];
goToEditMode: () => void; goToEditMode: () => void;
} }
function CountdownContents({ candidates, rundownData, subscriptions, goToEditMode }: CountdownContentsProps) { function CountdownContents({ playableEvents, subscriptions, goToEditMode }: CountdownContentsProps) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const { hidePast } = useCountdownOptions(); const { hidePast } = useCountdownOptions();
if (subscriptions.length === 0) { if (subscriptions.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-container'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -129,15 +107,13 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
); );
} }
const subscribedEvents = getOrderedSubscriptions(subscriptions, candidates) const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents);
.map((entry) => resolveSubscriptionTarget(entry, rundownData))
.filter((target): target is NonNullable<typeof target> => target !== null);
const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast); const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast);
if (subscribedEvents.length === 0) { if (subscribedEvents.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-container'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -153,8 +129,8 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) { if (eventsToShow.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-container'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-container' />
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { EntryId, OntimeEvent, OntimeGroup, isOntimeGroup } from 'ontime-types'; import { EntryId, PlayableEvent } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5'; import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -12,7 +12,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSelectProps { interface CountdownSelectProps {
events: ExtendedEntry<OntimeEvent | OntimeGroup>[]; events: ExtendedEntry<PlayableEvent>[];
subscriptions: EntryId[]; subscriptions: EntryId[];
disableEdit: () => void; disableEdit: () => void;
} }
@@ -52,15 +52,13 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
return ( return (
<div className='list-container'> <div className='list-container'>
{events.map((event) => { {events.map((event, index) => {
const title = event.title || '{no title}'; const title = event.title || '{no title}';
const isSelected = selectedIds.has(event.id); const isSelected = selectedIds.has(event.id);
const isGroup = isOntimeGroup(event);
const isGroupedEvent = !isGroup && Boolean(event.parent);
return ( return (
<div <div
key={event.id} key={index}
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={() => toggleSelect(event.id)} onClick={() => toggleSelect(event.id)}
@@ -70,33 +68,21 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
e.stopPropagation(); e.stopPropagation();
} }
}} }}
className={cx([ className={cx(['sub', isSelected && 'sub--selected'])}
'sub',
isSelected && 'sub--selected',
isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
> >
<div <div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
className='sub__binder'
style={{ '--user-color': event?.colour ?? '', '--group-color': event.groupColour ?? 'transparent' }}
/>
<div className='sub__schedule'> <div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' /> <ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' /> <ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
</div> </div>
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div> <div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'> <div className='sub__title'>{title}</div>
{isGroup && <span className='sub__eyebrow'>Group</span>}
{title}
</div>
</div> </div>
); );
})} })}
<div className='fab-container'> <div className='fab-container'>
<div className='selection-count'>{selected.length} selected</div>
<Button variant='subtle' size='xlarge' onClick={disableEdit}> <Button variant='subtle' size='xlarge' onClick={disableEdit}>
<IoArrowBack /> Go back <IoArrowBack /> Go back
</Button> </Button>
@@ -19,7 +19,6 @@ import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import { import {
CountdownEvent, CountdownEvent,
CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
isOutsideRange, isOutsideRange,
@@ -31,7 +30,7 @@ import {
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSubscriptionsProps { interface CountdownSubscriptionsProps {
subscribedEvents: CountdownTarget[]; subscribedEvents: ExtendedEntry<OntimeEvent>[];
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -99,39 +98,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => { {subscribedEvents.map((event) => {
// while a group is live, surface the running event's title as the secondary line const secondaryData = getPropertyValue(event, secondarySource);
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; const isLive = getIsLive(event.id, selectedEventId, playback);
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); const isArmed = !isLive && event.id === selectedEventId;
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData); const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title'); const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div <div
key={event.id} key={event.id}
ref={isLive ? selectedRef : undefined} ref={isLive ? selectedRef : undefined}
className={cx([ className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue} data-testid={event.cue}
> >
<div <div className='sub__binder' style={{ '--user-color': event.colour }} />
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} /> <ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} /> <SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}> <div className={cx(['sub__title', !displayTitle && 'subdued'])}>{displayTitle}</div>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} {secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div> </div>
); );
@@ -11,13 +11,13 @@ import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils'; import { useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions'; import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss'; import './SingleEventCountdown.scss';
interface SingleEventCountdownProps { interface SingleEventCountdownProps {
subscribedEvent: CountdownTarget; subscribedEvent: ExtendedEntry<OntimeEvent>;
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -38,13 +38,11 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode, mode,
}); });
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null }; const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt }; const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title'); const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing; const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
// while a group is live, surface the running event's title as the secondary line const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
const liveTitle = subscribedEvent.isGroup && subscribedEvent.liveEntry ? subscribedEvent.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(subscribedEvent, secondarySource);
return ( return (
<div className='single-container' data-testid='countdown-event'> <div className='single-container' data-testid='countdown-event'>
@@ -80,7 +80,7 @@ export const getCountdownOptions = (
{ {
id: 'sub', id: 'sub',
title: 'Event subscription', title: 'Event subscription',
description: 'The events and groups to follow', description: 'The events to follow',
values: persistedSubscriptions, values: persistedSubscriptions,
type: 'persist', type: 'persist',
}, },
@@ -1,128 +0,0 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils';
/**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
* Only the fields exercised by resolveSubscriptionTarget are provided; the rest are cast away.
*/
function makeEvent(patch: Partial<ExtendedEntry<OntimeEvent>>): ExtendedEntry<OntimeEvent> {
return {
id: 'event',
type: SupportedEntry.Event,
title: 'event title',
cue: '1',
colour: '',
skip: false,
parent: null,
timeStart: 0,
timeEnd: 0,
duration: 0,
delay: 0,
dayOffset: 0,
gap: 0,
countToEnd: false,
custom: {},
note: '',
// metadata
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
isPast: false,
...patch,
} as ExtendedEntry<OntimeEvent>;
}
function makeGroup(patch: Partial<ExtendedEntry<OntimeGroup>>): ExtendedEntry<OntimeGroup> {
return {
id: 'group',
type: SupportedEntry.Group,
title: 'group title',
colour: '#abcdef',
note: 'group note',
entries: [],
duration: 0,
custom: {},
timeStart: 0,
timeEnd: 0,
isPast: false,
isLoaded: false,
...patch,
} as ExtendedEntry<OntimeGroup>;
}
describe('resolveSubscriptionTarget()', () => {
it('returns events unchanged', () => {
const event = makeEvent({ id: 'e1' });
const result = resolveSubscriptionTarget(event, [event]);
expect(result).toBe(event);
});
it('derives group timing from the first playable child while keeping the group identity', () => {
const group = makeGroup({ id: 'g1', title: 'Session A', colour: '#123456', duration: 5000 });
const child1 = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1000, delay: 100, dayOffset: 0, title: 'Pres 1' });
const child2 = makeEvent({ id: 'c2', parent: 'g1', timeStart: 3000, title: 'Pres 2' });
const flat: ExtendedEntry<OntimeEntry>[] = [group, child1, child2];
const result = resolveSubscriptionTarget(group, flat);
expect(result).not.toBeNull();
// group identity and display
expect(result?.id).toBe('g1');
expect(result?.title).toBe('Session A');
expect(result?.colour).toBe('#123456');
expect(result?.isGroup).toBe(true);
// timing comes from the first child, duration from the group
expect(result?.timeStart).toBe(1000);
expect(result?.delay).toBe(100);
expect(result?.duration).toBe(5000);
expect(result?.countToEnd).toBe(false);
// state checks target the first child before any child is loaded
expect(result?.targetId).toBe('c1');
// report lookup targets the last child (session end)
expect(result?.reportId).toBe('c2');
});
it('returns null for a group with no playable children', () => {
const group = makeGroup({ id: 'g1' });
const result = resolveSubscriptionTarget(group, [group]);
expect(result).toBeNull();
});
it('skips skipped children when picking the first child', () => {
const group = makeGroup({ id: 'g1' });
const skipped = {
...makeEvent({ id: 'c0', parent: 'g1', timeStart: 500 }),
skip: true,
} as ExtendedEntry<OntimeEntry>;
const playable = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1500 });
const result = resolveSubscriptionTarget(group, [group, skipped, playable]);
expect(result?.timeStart).toBe(1500);
expect(result?.reportId).toBe('c1');
});
it('is live while any child is loaded and not past', () => {
const group = makeGroup({ id: 'g1' });
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
const child2 = makeEvent({ id: 'c2', parent: 'g1', isLoaded: true });
const child3 = makeEvent({ id: 'c3', parent: 'g1' });
const result = resolveSubscriptionTarget(group, [group, child1, child2, child3]);
expect(result?.isLoaded).toBe(true);
expect(result?.isPast).toBe(false);
expect(result?.liveEntry?.id).toBe('c2');
});
it('is past only once the last child is past and nothing is loaded', () => {
const group = makeGroup({ id: 'g1' });
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
const child2 = makeEvent({ id: 'c2', parent: 'g1', isPast: true });
const result = resolveSubscriptionTarget(group, [group, child1, child2]);
expect(result?.isLoaded).toBe(false);
expect(result?.isPast).toBe(true);
expect(result?.targetId).toBe('c1');
expect(result?.liveEntry).toBeNull();
});
});
@@ -1,17 +1,4 @@
import { import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
EntryId,
MaybeNumber,
OffsetMode,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeReport,
Playback,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils'; import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket'; import { useCountdownSocket } from '../../common/hooks/useSocket';
@@ -53,7 +40,7 @@ export const timerProgress: TimerMessage = {
* Handles events in different days but disregards whether an event has actually played * Handles events in different days but disregards whether an event has actually played
*/ */
export function useSubscriptionDisplayData( export function useSubscriptionDisplayData(
subscribedEvent: CountdownTarget & { endedAt: MaybeNumber; expectedStart: number }, subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } { ): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
const { playback, current, clock } = useCountdownSocket(); const { playback, current, clock } = useCountdownSocket();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
@@ -80,11 +67,7 @@ export function useSubscriptionDisplayData(
return { return {
status: 'live', status: 'live',
statusDisplay: getLocalizedString( statusDisplay: getLocalizedString(timerProgress['live']),
subscribedEvent.parent || (subscribedEvent.isGroup && subscribedEvent.liveEntry)
? 'countdown.group_running'
: timerProgress['live'],
),
timeDisplay: bigDuration(current ?? 0), timeDisplay: bigDuration(current ?? 0),
}; };
} }
@@ -185,75 +168,10 @@ export function isOutsideRange(a: number, b: number): boolean {
return Math.abs(a - b) > MILLIS_PER_MINUTE; return Math.abs(a - b) > MILLIS_PER_MINUTE;
} }
/** export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
* A subscription target normalised to the event-shaped object the countdown display consumes.
* For events this is a pass-through; for groups it carries the group identity and display data
* while deriving timing from the group's first playable child (see resolveSubscriptionTarget).
*/
export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
isGroup?: boolean;
targetId?: EntryId; // child entry used for countdown/live/armed state checks
reportId?: EntryId; // entry used for the report lookup (e.g. last child of a group)
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
* - Events are returned unchanged.
* - Groups count down to their first playable child while displaying the group identity.
* Status is group-aware: live while any child is loaded, past only once the whole group has finished.
* Returns null when a group has no playable children (nothing to count down to).
*/
export function resolveSubscriptionTarget(
entry: ExtendedEntry<OntimeEvent | OntimeGroup>,
flatRundown: ExtendedEntry<OntimeEntry>[],
): CountdownTarget | null {
if (!isOntimeGroup(entry)) {
return entry;
}
const children = flatRundown.filter(
(item): item is ExtendedEntry<PlayableEvent> =>
isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id,
);
if (children.length === 0) {
return null;
}
const firstChild = children[0];
const lastChild = children[children.length - 1];
const liveEntry = children.find((child) => child.isLoaded) ?? null;
const isLoaded = liveEntry !== null;
const isPast = !isLoaded && lastChild.isPast;
return {
// timing derives from the first playable child
...firstChild,
// group identity and display data
id: entry.id,
parent: null,
title: entry.title,
colour: entry.colour,
note: entry.note,
custom: entry.custom,
duration: entry.duration,
countToEnd: false,
// group-aware status
isLoaded,
isPast,
// group markers
isGroup: true,
targetId: firstChild.id,
reportId: lastChild.id,
liveEntry,
};
}
export function extendEventData( export function extendEventData(
event: CountdownTarget, event: ExtendedEntry<OntimeEvent>,
currentDay: number, currentDay: number,
actualStart: MaybeNumber, actualStart: MaybeNumber,
plannedStart: MaybeNumber, plannedStart: MaybeNumber,
@@ -271,6 +189,6 @@ export function extendEventData(
offset, offset,
mode, mode,
}); });
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null }; const { endedAt } = reportData[event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt }; return { ...event, expectedStart, endedAt };
} }
@@ -20,7 +20,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-read-only', alias: 'cuesheet-read-only',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -42,7 +41,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-flag', alias: 'cuesheet-flag',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: 'flag', write: 'flag',
@@ -64,7 +62,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-default', alias: 'cuesheet-default',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}; };
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit); const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
@@ -79,7 +76,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-granular', alias: 'cuesheet-granular',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'cue,title', read: 'cue,title',
write: 'title', write: 'title',
@@ -0,0 +1,32 @@
import { parseField } from '../cuesheet.utils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
const testData1 = 1000;
const testData2 = 60000;
const testData3 = 600000;
expect(parseField('timeStart', testData1)).toBe('00:00:01');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('duration', testData3)).toBe('00:10:00');
});
it('returns an empty string on undefined fields', () => {
// @ts-expect-error -- testing user data with missing fields
expect(parseField('title')).toBe('');
});
describe('simply returns any other value in any other field', () => {
const testFields = [
{ field: 'nothing', value: '123' },
{ field: 'title', value: 'test' },
{ field: 'note', value: 'test' },
{ field: 'colour', value: 'test' },
];
testFields.forEach((testCase) => {
test(`${testCase.field}:${testCase.value}`, () => {
expect(parseField(testCase.field, testCase.value)).toBe(testCase.value);
});
});
});
});
@@ -0,0 +1,23 @@
import { CustomFields, MaybeNumber, OntimeEntryCommonKeys } from 'ontime-types';
import { millisToString } from 'ontime-utils';
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
/**
* @description parses a field for export
* @param {string} field
* @param {*} data
* @return {string}
*/
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber, { fallback: '' });
}
if (field === 'skip') {
return data ? 'x' : '';
}
return String(data ?? '');
};
@@ -54,51 +54,17 @@
} }
.footer { .footer {
width: 100%;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
color: $label-gray; color: $label-gray;
} }
.filterHint {
text-align: right;
}
.em { .em {
color: $ui-white; color: $ui-white;
margin-inline: 0.25rem; margin-inline: 0.25rem;
} }
.hints {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
color: $label-gray;
font-size: calc(1rem - 3px);
}
.hintItem {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.scrollContainer { .scrollContainer {
max-height: 70vh; max-height: 70vh;
overflow: auto; overflow: auto;
padding-top: 1rem; padding-top: 1rem;
} }
@media (max-width: 680px) {
.footer {
align-items: flex-start;
flex-direction: column;
}
.filterHint {
text-align: left;
}
}
+2 -20
View File
@@ -3,7 +3,6 @@ import { SupportedEntry } from 'ontime-types';
import { KeyboardEvent, useState } from 'react'; import { KeyboardEvent, useState } from 'react';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import useFinder from './useFinder'; import useFinder from './useFinder';
@@ -97,25 +96,8 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
} }
footerElements={ footerElements={
<div className={style.footer}> <div className={style.footer}>
<div className={style.hints}> Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
<span className={style.hintItem}> <span className={style.em}>title</span> to filter search.
<Kbd></Kbd>
<Kbd></Kbd>
Navigate
</span>
<span className={style.hintItem}>
<Kbd>Enter</Kbd>
Go
</span>
<span className={style.hintItem}>
<Kbd>Esc</Kbd>
Close
</span>
</div>
<div className={style.filterHint}>
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
<span className={style.em}>title</span>
</div>
</div> </div>
} }
/> />
@@ -1,8 +1,6 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
$timeline-height: 1rem; $timeline-height: 1rem;
$group-band-height: 0.5rem;
$group-band-gap: 0.25rem;
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color)); $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
.timelineContainer { .timelineContainer {
@@ -19,21 +17,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
position: relative; position: relative;
font-weight: 600; font-weight: 600;
height: 100%; height: 100%;
// reserve the top region (progress bar + gap + group band) with the card background box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
box-shadow: inset 0 calc(#{$timeline-height} + #{$group-band-gap} + #{$group-band-height}) 0 0
var(--card-background-color-override, $viewer-card-bg-color);
}
.groupBand {
height: $group-band-height;
margin-top: $group-band-gap;
width: 100%;
background: var(--group-colour, transparent);
display: flex;
align-items: center;
position: relative;
z-index: 2;
overflow: visible;
} }
.column { .column {
@@ -108,6 +92,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
border-top: 2px solid var(--background-color-override, $viewer-background-color); border-top: 2px solid var(--background-color-override, $viewer-background-color);
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300); box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
text-transform: capitalize;
white-space: normal; white-space: normal;
&[data-status='done'] { &[data-status='done'] {
@@ -134,7 +119,6 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
.status { .status {
width: fit-content; width: fit-content;
text-transform: uppercase;
} }
.status.due { .status.due {
+4 -1
View File
@@ -55,6 +55,10 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize); return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
}, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]); }, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
if (totalDuration === 0) {
return null;
}
// Pre-calculate event statuses // Pre-calculate event statuses
let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future'; let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
const statusMap: Record<string, ProgressStatus> = {}; const statusMap: Record<string, ProgressStatus> = {};
@@ -101,7 +105,6 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
title={displayTitle} title={displayTitle}
cue={event.cue} cue={event.cue}
width={position.width} width={position.width}
groupColour={event.groupColour}
/> />
); );
})} })}
@@ -1,8 +1,8 @@
import { Day } from 'ontime-types'; import { Day } from 'ontime-types';
import { CSSProperties, RefObject } from 'react'; import { CSSProperties, RefObject } from 'react';
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket'; import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress';
import { alpha, cx } from '../../common/utils/styleUtils'; import { alpha, cx } from '../../common/utils/styleUtils';
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time'; import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
@@ -26,7 +26,6 @@ interface TimelineEntryProps {
title: string; title: string;
width: number; width: number;
cue: string; cue: string;
groupColour?: string;
ref?: RefObject<HTMLDivElement | null>; ref?: RefObject<HTMLDivElement | null>;
} }
@@ -49,7 +48,6 @@ export function TimelineEntry({
title, title,
width, width,
cue, cue,
groupColour,
ref, ref,
}: TimelineEntryProps) { }: TimelineEntryProps) {
const formattedStartTime = formatTime(start, formatOptions); const formattedStartTime = formatTime(start, formatOptions);
@@ -69,7 +67,6 @@ export function TimelineEntry({
{ {
'--color': colour, '--color': colour,
'--lighter': lighterColour ?? '', '--lighter': lighterColour ?? '',
'--group-colour': groupColour ?? 'transparent',
left: `${left}px`, left: `${left}px`,
width: `${width}px`, width: `${width}px`,
} as CSSProperties } as CSSProperties
@@ -77,7 +74,6 @@ export function TimelineEntry({
data-testid={cue} data-testid={cue}
> >
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />} {status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
<div className={style.groupBand} />
<div <div
className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])} className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
data-status={status} data-status={status}
@@ -157,8 +153,6 @@ function TimelineEntryStatus({
statusText = getLocalizedString('timeline.live'); statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') { } else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due'); statusText = getLocalizedString('timeline.due');
} else if (statusText === 'done') {
statusText = getLocalizedString('timeline.done');
} }
const isDue = status === 'future' && timeToStart <= 0; const isDue = status === 'future' && timeToStart <= 0;
@@ -169,7 +163,7 @@ function TimelineEntryStatus({
/** Generates a block level progress bar */ /** Generates a block level progress bar */
function ActiveBlock() { function ActiveBlock() {
const { current, duration } = useTimer(); const { current, duration } = useTimer();
const progress = useAnimatedProgress(current, duration); const progress = getProgress(current, duration);
return ( return (
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} /> <div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
); );
@@ -38,8 +38,6 @@ export default function TimelinePageLoader() {
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) { function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { mainSource, timeformat } = useTimelineOptions(); const { mainSource, timeformat } = useTimelineOptions();
const { getLocalizedString } = useTranslation();
// holds copy of the rundown with only relevant events // holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId); const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
@@ -52,8 +50,6 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]); const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
const hasContent = totalDuration > 0;
return ( return (
<div className='timeline' data-testid='timeline-view'> <div className='timeline' data-testid='timeline-view'>
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} /> <ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
@@ -65,16 +61,12 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} /> <TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
{hasContent ? ( <Timeline
<Timeline firstStart={firstStart}
firstStart={firstStart} rundown={scopedRundown}
rundown={scopedRundown} selectedEventId={selectedEventId}
selectedEventId={selectedEventId} totalDuration={totalDuration}
totalDuration={totalDuration} />
/>
) : (
<EmptyPage text={getLocalizedString('common.no_data')} />
)}
</div> </div>
); );
} }
@@ -20,7 +20,7 @@ export default function TimelineSections({ now, next, followedBy, mainSource }:
const state = useExpectedStartData(); const state = useExpectedStartData();
// gather card data // gather card data
const titleNow = now ? (getPropertyValue(now, mainSource ?? 'title') ?? '-') : getLocalizedString('timeline.standby'); const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-';
const dueText = getLocalizedString('timeline.due').toUpperCase(); const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-'; const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-'; const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
@@ -96,77 +96,63 @@ export function useScopedRundown(
): ScopedRundownData { ): ScopedRundownData {
const { hidePast } = useTimelineOptions(); const { hidePast } = useTimelineOptions();
const data = useMemo( const data = useMemo(() => {
() => computeScopedRundown(rundown, selectedEventId, hidePast), if (rundown.length === 0) {
[hidePast, rundown, selectedEventId], return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
); }
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
// we only deal with playableEvents
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}, [hidePast, rundown, selectedEventId]);
return data; return data;
} }
/**
* Pure computation behind useScopedRundown: filters to playable events and derives timers
*/
export function computeScopedRundown(
rundown: ExtendedEntry<OntimeEntry>[],
selectedEventId: MaybeString,
hidePast: boolean,
): ScopedRundownData {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart: number | null = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
// we only deal with playableEvents
const currentEntry = rundown[i];
if (!isOntimeEvent(currentEntry) || !isPlayableEvent(currentEntry)) {
continue;
}
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}
type UpcomingEvents = { type UpcomingEvents = {
now: ExtendedEntry<OntimeEvent> | null; now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null; next: ExtendedEntry<OntimeEvent> | null;
-5
View File
@@ -71,11 +71,6 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
configure: logProxyRequests, configure: logProxyRequests,
}, },
'^/mcp': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/ws*': { '^/ws*': {
target: 'http://localhost:4001/', target: 'http://localhost:4001/',
changeOrigin: true, changeOrigin: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.10.0", "version": "4.9.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/resolver", "name": "@getontime/resolver",
"version": "4.10.0", "version": "4.9.0",
"type": "module", "type": "module",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
@@ -20,7 +20,7 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"devDependencies": { "devDependencies": {
"ontime-types": "workspace:^4.2.1", "ontime-types": "workspace:^4.2.1",
"tsdown": "^0.22.4", "tsdown": "^0.21.5",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"files": [ "files": [
+1 -2
View File
@@ -1,7 +1,6 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"isolatedDeclarations": true, "ignoreDeprecations": "6.0"
"allowJs": false
} }
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.10.0", "version": "4.9.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -110,13 +110,14 @@ type old_URLPreset = {
/** /**
* migrates a url presets from v3 to v4 * migrates a url presets from v3 to v4
* - pathAndParams split into a target and search * - pathAndParams split into a target and search
*
*/ */
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined { export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) { if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset; const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => { const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
const [target, search] = pathAndParams.split('?'); const [target, search] = pathAndParams.split('?');
return { enabled, alias, target, search, displayInNav: false, options: {} } as URLPreset; return { enabled, alias, target, search, options: {} } as URLPreset;
}); });
return newURLPreset; return newURLPreset;
} }
@@ -208,7 +208,6 @@ describe('v3 to v4', () => {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {}, options: {},
}, },
{ {
@@ -217,7 +216,6 @@ describe('v3 to v4', () => {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -704,14 +704,11 @@ export async function renameRundown(id: string, title: string) {
await dataProvider.setRundown(id, { ...rundown, title }); await dataProvider.setRundown(id, { ...rundown, title });
/** /**
* If we are modifying the loaded rundown we re-init it * If loaded we re-init the rundown
* This is likely over-kill but the simplest way to ensure state consistency * This is likely over-kill but the simplest way to ensure state consistency
*/ */
if (isCurrentRundown(id)) { if (isCurrentRundown(id)) {
const rundown = dataProvider.getRundown(id); await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields());
const customField = dataProvider.getCustomFields();
// init rundown does its own refetch
await initRundown(rundown, customField);
} else { } else {
setImmediate(() => { setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns); sendRefetch(RefetchKey.ProjectRundowns);
@@ -12,9 +12,7 @@ describe('parseUrlPresets()', () => {
it('parses data, skipping invalid results', () => { it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn(); const errorEmitter = vi.fn();
const urlPresets = [ const urlPresets = [{ enabled: true, alias: 'alias', target: 'timer', search: 'ss' }] as URLPreset[];
{ enabled: true, alias: 'alias', target: 'timer', search: 'ss', displayInNav: false },
] as URLPreset[];
const result = parseUrlPresets({ urlPresets }, errorEmitter); const result = parseUrlPresets({ urlPresets }, errorEmitter);
expect(result.length).toEqual(1); expect(result.length).toEqual(1);
expect(result.at(0)).toMatchObject({ expect(result.at(0)).toMatchObject({
@@ -38,7 +36,6 @@ describe('parseUrlPresets()', () => {
alias: 'testalias', alias: 'testalias',
target: 'timer', target: 'timer',
search: 'testpathAndParams', search: 'testpathAndParams',
displayInNav: false,
}, },
], ],
} as unknown as DatabaseModel; } as unknown as DatabaseModel;
@@ -26,7 +26,6 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
alias: preset.alias, alias: preset.alias,
target: preset.target, target: preset.target,
search: preset.search ?? '', search: preset.search ?? '',
displayInNav: preset.displayInNav ?? false,
options: preset?.options, options: preset?.options,
}; };
newPresets.push(newPreset); newPresets.push(newPreset);
@@ -21,7 +21,6 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
alias: req.body.alias, alias: req.body.alias,
target: req.body.target, target: req.body.target,
search: req.body.search, search: req.body.search,
displayInNav: req.body.displayInNav,
options: req.body.options, options: req.body.options,
}; };
@@ -56,7 +55,6 @@ router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<U
alias: req.body.alias, alias: req.body.alias,
target: req.body.target, target: req.body.target,
search: req.body.search, search: req.body.search,
displayInNav: req.body.displayInNav,
options: req.body.options ?? existingPreset.options, options: req.body.options ?? existingPreset.options,
}; };
@@ -12,7 +12,6 @@ export const validateNewPreset = [
body('alias').isString().trim().notEmpty(), body('alias').isString().trim().notEmpty(),
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)), body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
body('search').isString().trim(), body('search').isString().trim(),
body('displayInNav').isBoolean(),
// options are currently only provided for cuesheet presets // options are currently only provided for cuesheet presets
body('options').optional().isObject(), body('options').optional().isObject(),
@@ -28,7 +27,6 @@ export const validateUpdatePreset = [
body('alias').isString().trim().notEmpty(), body('alias').isString().trim().notEmpty(),
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)), body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
body('search').isString().trim(), body('search').isString().trim(),
body('displayInNav').isBoolean(),
// options are currently only provided for cuesheet presets // options are currently only provided for cuesheet presets
body('options').optional().isObject(), body('options').optional().isObject(),
+65
View File
@@ -0,0 +1,65 @@
# Ontime MCP Server
Ontime exposes an MCP server over Streamable HTTP.
## Endpoint
Default local URL:
```text
http://localhost:4001/mcp
```
If Ontime is running on another host or port, replace `localhost:4001` with that address.
If `ROUTER_PREFIX` is configured, include it before `/mcp`, for example:
```text
http://localhost:4001/stage/mcp
```
The MCP route is stateless. Clients should send MCP requests with `POST`; `GET` and
`DELETE` are not used.
## Authentication
If Ontime has no session password configured, no MCP authentication is required.
If a session password is configured, authenticate with the hashed Ontime token.
The settings UI generates an endpoint URL with the token in the query string:
```text
http://localhost:4001/mcp?token=<hashed-token>
```
If your MCP client supports request headers, you can send the same token as a
bearer token instead:
```http
Authorization: Bearer <hashed-token>
```
This is the same token used in authenticated Ontime share URLs as the `token` query
parameter. The raw session password is not accepted as the bearer token.
## Client Configuration
Use a Streamable HTTP MCP client and point it at the MCP endpoint:
```json
{
"mcpServers": {
"ontime": {
"url": "http://localhost:4001/mcp?token=<hashed-token>"
}
}
}
```
Omit the token when Ontime is not password protected. If you prefer headers, use
`"url": "http://localhost:4001/mcp"` and add an `Authorization` header with the
same bearer token.
## Quick Check
The server should respond to MCP initialization requests at `/mcp`. A plain browser
`GET` request will return `405 Method not allowed`, which is expected.
@@ -6,13 +6,11 @@ const editEntryMock = vi.hoisted(() => vi.fn());
const groupEntriesMock = vi.hoisted(() => vi.fn()); const groupEntriesMock = vi.hoisted(() => vi.fn());
const ungroupEntriesMock = vi.hoisted(() => vi.fn()); const ungroupEntriesMock = vi.hoisted(() => vi.fn());
const getCurrentRundownMock = vi.hoisted(() => vi.fn()); const getCurrentRundownMock = vi.hoisted(() => vi.fn());
const getProjectCustomFieldsMock = vi.hoisted(() => vi.fn());
const createCustomFieldMock = vi.hoisted(() => vi.fn());
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({ vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
getCurrentRundown: getCurrentRundownMock, getCurrentRundown: getCurrentRundownMock,
getCurrentRundownId: vi.fn(() => 'loaded-rundown'), getCurrentRundownId: vi.fn(() => 'loaded-rundown'),
getProjectCustomFields: getProjectCustomFieldsMock, getProjectCustomFields: vi.fn(() => ({})),
})); }));
vi.mock('../../classes/data-provider/DataProvider.js', () => ({ vi.mock('../../classes/data-provider/DataProvider.js', () => ({
@@ -22,7 +20,6 @@ vi.mock('../../classes/data-provider/DataProvider.js', () => ({
vi.mock('../../api-data/rundown/rundown.service.js', () => ({ vi.mock('../../api-data/rundown/rundown.service.js', () => ({
addEntry: addEntryMock, addEntry: addEntryMock,
batchEditEntries: vi.fn(), batchEditEntries: vi.fn(),
createCustomField: createCustomFieldMock,
deleteEntries: vi.fn(), deleteEntries: vi.fn(),
editEntry: editEntryMock, editEntry: editEntryMock,
groupEntries: groupEntriesMock, groupEntries: groupEntriesMock,
@@ -30,8 +27,7 @@ vi.mock('../../api-data/rundown/rundown.service.js', () => ({
ungroupEntries: ungroupEntriesMock, ungroupEntries: ungroupEntriesMock,
})); }));
const { batchCreateEntriesForMcp, createCustomFieldForMcp, createEntryForMcp, groupEntriesForMcp, ungroupEntryForMcp } = const { batchCreateEntriesForMcp, groupEntriesForMcp, ungroupEntryForMcp } = await import('../mcp.service.js');
await import('../mcp.service.js');
function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown { function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown {
return { return {
@@ -48,7 +44,6 @@ describe('mcp.service', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
getCurrentRundownMock.mockReturnValue(makeRundown({})); getCurrentRundownMock.mockReturnValue(makeRundown({}));
getProjectCustomFieldsMock.mockReturnValue({});
let id = 0; let id = 0;
addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => { addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => {
@@ -257,42 +252,4 @@ describe('mcp.service', () => {
expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group'); expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group');
expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] }); expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] });
}); });
describe('createCustomFieldForMcp', () => {
it('creates a field and returns the derived key', async () => {
createCustomFieldMock.mockResolvedValue({
Camera_Angle: { label: 'Camera Angle', type: 'text', colour: '#3E75E8' },
});
const result = await createCustomFieldForMcp({ label: 'Camera Angle', type: 'text', colour: '#3E75E8' });
expect(createCustomFieldMock).toHaveBeenCalledWith({ label: 'Camera Angle', type: 'text', colour: '#3E75E8' });
expect(result.key).toBe('Camera_Angle');
});
it('rejects labels the editor UI would not accept', async () => {
await expect(createCustomFieldForMcp({ label: 'Camera/GFX', type: 'text', colour: '#000000' })).rejects.toThrow(
'Invalid label',
);
expect(createCustomFieldMock).not.toHaveBeenCalled();
});
it('rejects case-insensitive duplicates and points at the existing key', async () => {
getProjectCustomFieldsMock.mockReturnValue({ Camera: { label: 'Camera', type: 'text', colour: '' } });
await expect(createCustomFieldForMcp({ label: 'camera', type: 'text', colour: '#000000' })).rejects.toThrow(
'A custom field with key "Camera" (label "Camera") already exists.',
);
expect(createCustomFieldMock).not.toHaveBeenCalled();
});
});
it('suggests the correctly cased key when custom values use the wrong casing', async () => {
getProjectCustomFieldsMock.mockReturnValue({ Camera: { label: 'Camera', type: 'text', colour: '' } });
await expect(createEntryForMcp({ title: 'Talk', custom: { camera: 'CAM 2' } })).rejects.toThrow(
'Keys are case-sensitive — did you mean: "camera" → "Camera"?',
);
expect(addEntryMock).not.toHaveBeenCalled();
});
}); });
@@ -1,56 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/project-data/projectData.dao.js', () => ({
getProjectData: vi.fn(() => ({ title: 'Test project' })),
}));
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
getCurrentRundown: vi.fn(() => ({
id: 'rundown-1',
title: 'Test',
order: [],
flatOrder: [],
entries: {},
revision: 0,
})),
getProjectCustomFields: vi.fn(() => ({})),
}));
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
getDataProvider: vi.fn(() => ({ getProjectRundowns: () => ({}) })),
}));
const { PROMPT_DEFINITIONS, handleGetPrompt } = await import('../mcp.prompts.js');
const { RESOURCE_DEFINITIONS, handleReadResource } = await import('../mcp.resources.js');
describe('MCP resource wiring', () => {
it('serves non-empty content for every listed resource', () => {
for (const resource of RESOURCE_DEFINITIONS) {
const result = handleReadResource(resource.uri);
expect(result.contents).toHaveLength(1);
expect(result.contents[0].uri).toBe(resource.uri);
expect(result.contents[0].mimeType).toBe(resource.mimeType);
expect((result.contents[0].text as string).length).toBeGreaterThan(0);
}
});
it('rejects unknown resource URIs', () => {
expect(() => handleReadResource('ontime://nope')).toThrow('Unknown resource URI: ontime://nope');
});
});
describe('MCP prompt wiring', () => {
it('resolves every listed prompt', () => {
for (const prompt of PROMPT_DEFINITIONS) {
const args = Object.fromEntries((prompt.arguments ?? []).map((arg) => [arg.name, 'test value']));
const result = handleGetPrompt(prompt.name, args);
expect(result.messages.length).toBeGreaterThan(0);
const content = result.messages[0].content;
expect(content.type).toBe('text');
}
});
it('rejects unknown prompts', () => {
expect(() => handleGetPrompt('nope', {})).toThrow('Unknown prompt: nope');
});
});
-103
View File
@@ -1,103 +0,0 @@
/**
* Agent-facing Ontime guidance documents.
*
* These markdown resources teach agents rundown craft (ontime://style-guide)
* and Ontime's view ecosystem (ontime://views). Guidance should be concise and
* clear verbosity distracts agents and users alike. Keep each document short.
*/
// The palette mirrors the editor colour picker swatches in
// apps/client/src/common/components/input/colour-input/SwatchSelect.tsx — keep in sync.
export const ONTIME_STYLE_GUIDE_MARKDOWN = `# Ontime rundown style guide
Conventions for tidy, scannable rundowns. Read before creating or restyling a rundown. Follow the user's own conventions when they have them; otherwise prefer these. When communicating with the user, be concise short proposals beat long explanations.
## Structure
- Group events into blocks matching the show's segments; 10+ ungrouped events is a wall propose groups.
- Set \`targetDuration\` on groups so operators can track time to block completion.
- Use \`milestone\` for non-timed markers (doors open, broadcast start), not zero-duration events.
- Use \`delay\` only to model schedule drift, never as padding.
- Schedule changeovers and buffers as real events (grey; orange when technical), not invisible gaps 510 min between major segments stops one overrun cascading through the day.
- Model contingency segments as \`skip: true\` events: visible in the rundown, excluded from playback, one edit to activate.
- Before show day, duplicate the rundown for rehearsal so the show copy stays clean.
## Colours
Prefer Ontime's default palette — the editor's swatches so colours look native. Each hue has a dark/light tone pair:
| Hue | Dark | Light | Use |
| --- | --- | --- | --- |
| Red | \`#ED3333\` | \`#FF7878\` | attention: curfews, hard deadlines, broadcast-critical |
| Orange | \`#FFAB33\` | \`#FFCC78\` | technical changeovers, resets |
| Green | \`#339E4E\` | \`#77C785\` | normal show content |
| Blue | \`#3E75E8\` | \`#779BE7\` | normal show content |
| Violet | \`#8064E1\` | \`#A790F5\` | normal show content |
| Grey | \`#9d9d9d\` | \`#ececec\` | breaks, housekeeping |
- Normal segments use the calm hues (green, blue, violet). Reserve red for what genuinely needs attention and orange for changeovers.
- Use tones for hierarchy: dark tone on a group, the light tone of the same hue on its child events.
- One convention per project, applied consistently.
## Cues and titles
- Cues follow ONE convention per rundown: numeric (\`1, 2, 3\` or \`10, 20, 30\`, leaving room for insertions), semantic (\`Preset\`, \`Key 1\`, \`Lunch\`), or a deliberate mix. Dense technical shows often prefix by department (\`LX1\`, \`SND2\`, \`VID3\`). Match what the rundown already uses; ask if nothing is established.
- Cues are automation handles external systems (Companion, OSC/HTTP) trigger events by cue name. Keep them unique and stable; warn before renaming cues on an existing show.
- Title = what happens on stage, under ~50 characters. Department/tech data goes in custom fields; \`note\` is for free-text human context.
## Custom fields
- Structured per-department data (camera, graphics, audio, speaker) belongs in custom fields one per department, coloured to match. This powers the cuesheet and operator views.
- Reuse existing fields; never create near-duplicates (\`Cam\`, \`camera\`, \`Cameras\`).
- Data repeated in titles or notes ("CAM 2") is a sign it belongs in a custom field.
- Write values to be scannable mid-show: "CAM 2 close-up", not sentences.
## Timing
- \`linkStart\` events inside a block so schedule edits propagate.
- \`flag\` sparingly — it marks critical moments and dilutes with overuse.
- Set \`timeWarning\` where a moderator would signal the speaker to wrap (commonly 5 min); \`timeDanger\` for the final push (e.g. 1 min). Scale both down for short items.
- Hard out? Backtime: build the closing items from the end with \`timeEnd\` + \`duration\` (Ontime calculates \`timeStart\`), protect their ends with \`timeStrategy: lock-end\`, and use \`countToEnd\` for true curfew items.
After tidying, read \`ontime://views\` and suggest views that use the new structure.
`;
export const ONTIME_VIEWS_MARKDOWN = `# Ontime views and sharing guide
Ontime renders the rundown into role-specific views at \`http://<host>:<port>/<view>\`. Recommend the right view when the user mentions a role, device, or sharing need. Prefer sharing live view links over exporting static documents — everyone sees the current version, ending the conflicting-copies problem of circulated spreadsheets. The agent cannot create views or presets — give exact URLs and point to the editor settings.
| View | Path | For |
| --- | --- | --- |
| Timer | \`/timer\` | speakers/presenters on stage |
| Backstage | \`/backstage\` | crew following schedule + notes |
| Operator | \`/op\` | department operators on phone/tablet |
| Cuesheet | \`/cuesheet\` | cross-department table editing |
| Timeline | \`/timeline\` | producers, whole-show overview |
| Countdown | \`/countdown\` | following selected events or a group |
| Studio clock | \`/studio\` | on-air studio environments |
| Project info | \`/info\` | sharing event logistics widely |
Most views are configured via URL parameters (editor and cuesheet in-app); the view's cog icon exposes the options and encodes them into the URL.
## Operator view parameters
- \`main\` / \`secondary-src\`: the two text lines — \`title\`, \`note\`, or \`custom-<fieldKey>\`.
- \`subscribe=<fieldKey>\`: highlight a custom field (repeatable).
- \`shouldEdit=true\`: long-press editing of the highlighted field.
- \`hidePast=true\`, \`showStart=true\`: trim and annotate the list.
Example for a camera operator:
\`/op?main=title&secondary-src=custom-Camera&subscribe=Camera&hidePast=true\`
Offer a link like this after creating a department custom field.
## URL presets
Presets bundle a configured view under a short alias (e.g. \`/camera-op\`), optionally shown in the nav menu. Managed in **Editor → Settings → URL presets**. When recommending a configured view, spell out the view and query string and suggest saving it as a preset.
Docs: https://docs.getontime.no/features/url-presets/
## Custom views
For branding or layouts beyond the built-in options: files in Ontime's \`external\` folder are served at \`/external/…\` and can use Ontime's APIs for live data. Recommend for sponsor branding, unusual layouts, or LED wall graphics.
Docs: https://docs.getontime.no/features/custom-views/
`;
+6 -101
View File
@@ -33,29 +33,6 @@ export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
}, },
], ],
}, },
{
name: 'tidy_rundown',
description:
'Audit the rundown for readability (colours, grouping, cue naming, custom field usage) and propose improvements',
arguments: [
{
name: 'focus',
description: 'Optional area to focus on, e.g. "colours only" or "grouping"',
required: false,
},
],
},
{
name: 'manage_custom_fields',
description: 'List, create, rename, recolour, or delete project-level custom field definitions',
arguments: [
{
name: 'instruction',
description: 'What to do, e.g. "add a Speaker field" or "delete the Camera field"',
required: true,
},
],
},
]; ];
function userPrompt(description: string, text: string): GetPromptResult { function userPrompt(description: string, text: string): GetPromptResult {
@@ -102,7 +79,6 @@ Count to end (countToEnd):
- Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour. - Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour.
End action (endAction): End action (endAction):
- Controls what happens when the event's timer reaches zero and can have surprising effect. Use only if the user explicitly asks for it.
- \`none\` (default): stops at end; operator must manually start the next event. - \`none\` (default): stops at end; operator must manually start the next event.
- \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed. - \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed.
- \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap. - \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap.
@@ -117,17 +93,15 @@ Flags (flag):
- Set \`flag: true\` on events that are critical operational markers (keynote starts, broadcast moments, VIP arrivals). - Set \`flag: true\` on events that are critical operational markers (keynote starts, broadcast moments, VIP arrivals).
- The operator view shows a countdown to the next flagged event use sparingly for maximum impact. - The operator view shows a countdown to the next flagged event use sparingly for maximum impact.
Structure and colours: Colours:
- Read the ontime://style-guide resource before deciding on structure or colours. - Ask the user what colour convention they use before applying any colours.
- Propose a group per agenda section (morning block, keynotes, breaks) rather than a flat list in ontime_batch_create_entries this is just a group entry with \`children\`. - Common pattern: one colour per event type (keynotes, panels, breaks, meals).
- Ask the user what colour convention they use; if they have none, propose one from the default Ontime palette in the style guide: calm hues for normal content, red only for attention items, orange for changeovers, dark tone on a group with the light tone of the same hue on its children. Colours are hex strings: \`#RRGGBB\`. - Colours are hex strings: \`#RRGGBB\`.
Custom fields (custom): Custom fields (custom):
- Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker). - Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker).
- Store values per entry at \`custom: { <fieldKey>: <value> }\`keys are case-sensitive. - Store values per entry at \`custom: { <fieldKey>: <value> }\`only use keys that exist in the project.
- Reuse an existing field when it covers the concept (avoid duplicates such as \`Cam\`, \`camera\`, \`Cameras\`); when the agenda carries structured data with no matching field (speakers, locations), create it with ontime_create_custom_field and use the returned key — no confirmation needed. - If the user's requested field is ambiguous, show the existing field list before choosing a key, so you avoid duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
After creating the rundown, read ontime://views and suggest how the team can follow it — e.g. a timer view for speakers, a backstage view for crew, or an operator view subscribed to a department custom field.
Agenda: Agenda:
${args.agenda}`, ${args.agenda}`,
@@ -176,7 +150,6 @@ Schedule integrity:
- Events with \`duration\` of 0 or negative - Events with \`duration\` of 0 or negative
- Events with \`gap < 0\`: overlaps the previous timed event and is a conflict - Events with \`gap < 0\`: overlaps the previous timed event and is a conflict
- Large unexplained positive gaps between consecutive timed events (> 30 min): check whether these are intentional - Large unexplained positive gaps between consecutive timed events (> 30 min): check whether these are intentional
- Long segments scheduled back-to-back with no changeover or buffer between them: one overrun will cascade suggest a buffer event
- Events where \`timeEnd < timeStart\`: these cross midnight; confirm this is intentional - Events where \`timeEnd < timeStart\`: these cross midnight; confirm this is intentional
- Events whose \`timeStart\` is the same as or earlier than the previous playable event's \`timeStart\`: Ontime schedules these on the next day; confirm this is intentional - Events whose \`timeStart\` is the same as or earlier than the previous playable event's \`timeStart\`: Ontime schedules these on the next day; confirm this is intentional
@@ -196,13 +169,6 @@ Skipped events:
Totals: Totals:
- Total rundown duration and whether it matches the user's expected show length (ask if unknown) - Total rundown duration and whether it matches the user's expected show length (ask if unknown)
Readability (report at INFO level; see ontime://style-guide for the conventions):
- Colour usage without a consistent convention, or key segments left uncoloured
- Long runs of related ungrouped events that would read better as groups
- Duplicate cues (cues are automation handles and should be unique) or mixed cue naming conventions
- Structured department data in titles/notes that belongs in custom fields
- If several readability issues surface, suggest running the tidy_rundown prompt to fix them systematically
Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`, Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`,
); );
} }
@@ -237,66 +203,5 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
); );
} }
if (name === 'tidy_rundown') {
const focus = args.focus ? `\nFocus specifically on: "${args.focus}".` : '';
return userPrompt(
'Audit and improve rundown readability',
`Audit the current Ontime rundown for readability and tidiness, then propose improvements.${focus}
Steps:
1. Read the ontime://style-guide resource — it defines the conventions to audit against, including the standard colour palette.
2. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
3. Call ontime_get_rundown with the chosen \`rundownId\`, and ontime_get_custom_fields for the project field definitions.
4. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it.
Audit for:
- Colour consistency: entries coloured outside a discernible convention, missing colours on key segments, red/orange used for normal content (the style guide reserves them for attention items and changeovers), or group/children colours that ignore the dark/light tone pairing. Prefer the default Ontime palette.
- Grouping: long runs (roughly 4+) of related consecutive top-level events that would read better as a named group; groups missing \`targetDuration\`.
- Buffers: long segments back-to-back with no changeover event between them; propose short buffer events so one overrun cannot cascade.
- Cue naming: mixed conventions (numeric vs semantic) without apparent intent, duplicate cues, or gaps that suggest mistakes. Remember cues are automation handles renaming them can break external triggers, so flag renames explicitly.
- Data in the wrong place: structured department data buried in titles or notes (e.g. "CAM 2", "GFX: lower third") that belongs in a custom field; overlong titles (over ~50 chars).
- Entry types: zero-duration events that should be milestones; milestones that carry timing expectations and should be events.
- Flags: \`flag: true\` on many events dilutes its meaning — critical markers only.
Then:
5. Present the findings as a short, scannable proposal (what changes, on which entries, and why) and WAIT for the user to approve before mutating anything. Be concise a tight list beats prose.
6. Apply approved changes with the cheapest calls: ontime_group_entries to group existing entries, ontime_batch_update_entries when several entries get the same values (e.g. recolouring), ontime_update_entry for per-entry changes, ontime_create_custom_field when moving data into a new field.
7. Finish by reading ontime://views and suggesting views that put the improved structure to work — e.g. an operator view subscribed to a department custom field, or a cuesheet for cross-department editing.`,
);
}
if (name === 'manage_custom_fields') {
return userPrompt(
'List, create, rename, recolour, or delete custom field definitions',
`Manage the project-level custom field definitions for this Ontime project: "${args.instruction}"
Custom fields are project-scoped. They appear as columns in the cuesheet and can be set on any event, milestone, or group. Changes apply to all rundowns in the project.
Steps:
1. Call ontime_get_custom_fields to see what fields already exist. Reuse an existing field when it covers the concept avoid duplicates such as "Cam", "camera", and "Cameras".
2. Carry out the instruction using the tools below. Creating a field is non-destructive do it directly. Confirm destructive changes (rename, delete) with the user before calling.
Creating a field:
- Call ontime_create_custom_field with { label, type, colour }.
- label: human-readable name (letters, numbers and spaces). The key is auto-derived: spaces underscores (e.g. "Camera Angle" "Camera_Angle").
- type: "text" for short string values, "image" for image URLs. Cannot be changed after creation.
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. If unspecified, pick one from the default Ontime palette matching the department.
- Returns: { key, customFields } use the returned key when setting values on entries.
Renaming or recolouring a field:
- Call ontime_update_custom_field with { key, label?, colour? }.
- Warning: changing the label changes the derived key and cascades to all entry references in all rundowns. Confirm with the user before renaming.
- The field type cannot be changed.
Deleting a field:
- Call ontime_delete_custom_field with { key }.
- Warning: this permanently removes the field definition and its values from every entry in all rundowns. Confirm with the user before deleting.
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.
When a field was created for a department (camera, graphics, audio), read ontime://views and offer the views that surface it: the cuesheet shows the field as a column for cross-department editing, and the operator view can display or highlight it for that department, e.g. /op?main=title&secondary-src=custom-<key>&subscribe=<key>.`,
);
}
throw new Error(`Unknown prompt: ${name}`); throw new Error(`Unknown prompt: ${name}`);
} }
-19
View File
@@ -4,7 +4,6 @@ import { getProjectData } from '../api-data/project-data/projectData.dao.js';
import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js'; import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js'; import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../classes/data-provider/DataProvider.js';
import { ONTIME_STYLE_GUIDE_MARKDOWN, ONTIME_VIEWS_MARKDOWN } from './mcp.guides.js';
import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js'; import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js';
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
@@ -46,22 +45,6 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.', 'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.',
mimeType: 'application/json', mimeType: 'application/json',
}, },
{
uri: 'ontime://style-guide',
name: 'ontime-style-guide',
title: 'Rundown style guide',
description:
'Best practices for tidy, easy-to-read rundowns: grouping, colour conventions and the default Ontime palette, cue naming, custom fields vs notes, timing hygiene. Read before creating or restyling a rundown.',
mimeType: 'text/markdown',
},
{
uri: 'ontime://views',
name: 'ontime-views',
title: 'Views and sharing guide',
description:
'What each Ontime view (timer, backstage, operator, cuesheet, timeline, countdown, studio clock, project info) is for, key URL parameters, URL presets, and custom views. Read when recommending how a team member should follow or edit the show.',
mimeType: 'text/markdown',
},
{ {
uri: 'ontime://docs', uri: 'ontime://docs',
name: 'ontime-docs', name: 'ontime-docs',
@@ -74,8 +57,6 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = { const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = {
'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN }, 'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN },
'ontime://style-guide': { mimeType: 'text/markdown', read: () => ONTIME_STYLE_GUIDE_MARKDOWN },
'ontime://views': { mimeType: 'text/markdown', read: () => ONTIME_VIEWS_MARKDOWN },
'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN }, 'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN },
'ontime://rundown/current': { 'ontime://rundown/current': {
mimeType: 'application/json', mimeType: 'application/json',
+2 -15
View File
@@ -17,20 +17,9 @@ mcpRouter.post('/', async (req, res) => {
sessionIdGenerator: undefined, // stateless: no session tracking sessionIdGenerator: undefined, // stateless: no session tracking
enableJsonResponse: true, enableJsonResponse: true,
}); });
const server = createMcpServer(); res.on('close', () => transport.close());
let cleanedUp = false;
const cleanup = async () => {
if (cleanedUp) return;
cleanedUp = true;
transport.close();
await server.close?.();
};
res.on('close', () => void cleanup());
res.on('finish', () => void cleanup());
try { try {
const server = createMcpServer();
await server.connect(transport); await server.connect(transport);
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body); await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body);
} catch (error) { } catch (error) {
@@ -39,8 +28,6 @@ mcpRouter.post('/', async (req, res) => {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null }); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null });
} }
} finally {
await cleanup();
} }
}); });
+7 -28
View File
@@ -49,8 +49,7 @@ export const EVENT_WRITABLE_FIELDS = {
note: { type: 'string', description: 'Free-text note for production notes or references' }, note: { type: 'string', description: 'Free-text note for production notes or references' },
colour: { colour: {
type: 'string', type: 'string',
description: description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
}, },
skip: { type: 'boolean', description: 'If true, event is skipped during playback' }, skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
flag: { flag: {
@@ -61,7 +60,7 @@ export const EVENT_WRITABLE_FIELDS = {
type: 'object', type: 'object',
additionalProperties: { type: 'string' }, additionalProperties: { type: 'string' },
description: description:
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.', 'Custom field values keyed by existing project field key, e.g. { "camera": "CAM 2" }. Get available keys with ontime_get_custom_fields. Adding new custom field definitions is a separate project-level operation.',
}, },
...EVENT_TIMER_FIELDS, ...EVENT_TIMER_FIELDS,
} as const; } as const;
@@ -178,14 +177,8 @@ Examples:
## Custom fields ## Custom fields
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`. Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`.
Events, milestones and groups store values at \`entry.custom[fieldKey]\`. Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
Only use existing field keys when setting \`custom\` values. Adding, renaming, or deleting custom field definitions is a separate project-level operation.
Managing field definitions: When the user wants to assign custom values, show the existing custom field list first if there is any ambiguity, so you do not create duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
- Read: \`ontime_get_custom_fields\` — returns \`{ [key]: { label, type, colour } }\`
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). Returns \`{ key, customFields }\`.
- Rename/recolour: \`ontime_update_custom_field { key, label?, colour? }\` — renaming the label changes the derived key and cascades to all entry references across all rundowns. Type cannot be changed.
- Delete: \`ontime_delete_custom_field { key }\` — removes the field definition and its values from every entry in all rundowns. Destructive, confirm first.
Keys are case-sensitive. When setting \`custom\` values, reuse an existing field when it covers the concept; otherwise create the missing field — creation is non-destructive and needs no confirmation. Rename and delete are the destructive operations: confirm those with the user.
## Targeting rundowns ## Targeting rundowns
Entry read/write tools accept an optional \`rundownId\`. Entry read/write tools accept an optional \`rundownId\`.
@@ -199,8 +192,6 @@ When playback is not \`stop\`, mutations to the loaded rundown affect the live r
## Available resources ## Available resources
- \`ontime://schema\` — this document - \`ontime://schema\` — this document
- \`ontime://style-guide\` — rundown design best practices: grouping, colour palette, cue naming, custom field usage. Read before creating or restyling a rundown.
- \`ontime://views\` — Ontime's role-specific views, URL parameters, URL presets, and custom views. Read when recommending how a team member should follow or edit the show.
- \`ontime://rundown/current\` — the currently loaded rundown (JSON) - \`ontime://rundown/current\` — the currently loaded rundown (JSON)
- \`ontime://rundowns\` — all rundowns in the project (JSON) - \`ontime://rundowns\` — all rundowns in the project (JSON)
- \`ontime://project/info\` — project metadata (JSON) - \`ontime://project/info\` — project metadata (JSON)
@@ -234,20 +225,8 @@ Main site: https://docs.getontime.no
- URL presets / shared views: https://docs.getontime.no/features/url-presets/ - URL presets / shared views: https://docs.getontime.no/features/url-presets/
- HTTP Integration: https://docs.getontime.no/api/http/ - HTTP Integration: https://docs.getontime.no/api/http/
- OSC Integration: https://docs.getontime.no/api/osc/ - OSC Integration: https://docs.getontime.no/api/osc/
- Make custom views: https://docs.getontime.no/features/custom-views/
## Views ## API reference
- Configuring views: https://docs.getontime.no/quick-tips/configuring-views/ - REST API overview: https://docs.getontime.no/api/
- Operator view: https://docs.getontime.no/interface/production/operator/ - WebSocket events: https://docs.getontime.no/api/websocket/
- Cuesheet: https://docs.getontime.no/interface/production/cuesheet/
- Render views in third-party apps: https://docs.getontime.no/quick-tips/render-elsewhere/
## Quick tips
- Managing delays: https://docs.getontime.no/quick-tips/managing-delays/
- Smart time entry: https://docs.getontime.no/quick-tips/smart-time-entry/
## Helpful references
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/
- Import data from spreadsheets: https://docs.getontime.no/features/import-spreadsheet/
`; `;
+2 -57
View File
@@ -12,16 +12,12 @@ import {
Rundown, Rundown,
SupportedEntry, SupportedEntry,
} from 'ontime-types'; } from 'ontime-types';
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js'; import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import { import {
addEntry, addEntry,
batchEditEntries, batchEditEntries,
createCustomField,
deleteCustomField,
deleteEntries, deleteEntries,
editCustomField,
editEntry, editEntry,
groupEntries, groupEntries,
reorderEntry, reorderEntry,
@@ -107,26 +103,8 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
} }
if (unknownKeys.size > 0) { if (unknownKeys.size > 0) {
const missing = [...unknownKeys].join(', '); const keys = [...unknownKeys].join(', ');
const available = Object.keys(customFields); throw new Error(`Unknown custom field key(s): ${keys}. Call ontime_get_custom_fields to list available keys.`);
const hint =
available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
// keys are case-sensitive; a near-miss on casing is the most common mistake
const suggestions = [...unknownKeys]
.map((key) => {
const match = available.find((existing) => existing.toLowerCase() === key.toLowerCase());
return match ? `"${key}" → "${match}"` : null;
})
.filter(Boolean);
const caseHint =
suggestions.length > 0 ? ` Keys are case-sensitive — did you mean: ${suggestions.join(', ')}?` : '';
throw new Error(
`Unknown custom field key(s): ${missing}. ${hint}${caseHint} ` +
`Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` +
`the key is auto-derived from the label (spaces → underscores, e.g. label "Mix Output" → key "Mix_Output").`,
);
} }
} }
@@ -342,36 +320,3 @@ export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids:
const rundown = await batchEditEntries(rundownId, args.ids, args.data); const rundown = await batchEditEntries(rundownId, args.ids, args.data);
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order }; return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
} }
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
const label = args.label?.trim();
// same constraint the HTTP route enforces in customFields.validation.ts
if (!label || !checkRegex.isAlphanumericWithSpace(label)) {
throw new Error('Invalid label: use letters, numbers and spaces only, e.g. "Camera Angle".');
}
const key = customFieldLabelToKey(label);
const existingFields = getProjectCustomFields();
const clash = Object.keys(existingFields).find((existing) => existing.toLowerCase() === key.toLowerCase());
if (clash) {
throw new Error(
`A custom field with key "${clash}" (label "${existingFields[clash].label}") already exists. ` +
`Reuse it in entry.custom["${clash}"] instead of creating a duplicate, or pick a clearly different label.`,
);
}
const updated = await createCustomField({ label, type: args.type, colour: args.colour });
return { key, customFields: updated };
}
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
const projectRundowns = getDataProvider().getProjectRundowns();
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
return { customFields: updated };
}
export async function deleteCustomFieldForMcp(args: { key: string }) {
const projectRundowns = getDataProvider().getProjectRundowns();
const updated = await deleteCustomField(args.key, projectRundowns);
return { customFields: updated };
}
+2 -80
View File
@@ -25,9 +25,7 @@ import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
import { import {
batchCreateEntriesForMcp, batchCreateEntriesForMcp,
batchUpdateEntriesForMcp, batchUpdateEntriesForMcp,
createCustomFieldForMcp,
createEntryForMcp, createEntryForMcp,
deleteCustomFieldForMcp,
deleteEntriesForMcp, deleteEntriesForMcp,
findEntry, findEntry,
getRundownById, getRundownById,
@@ -35,7 +33,6 @@ import {
reorderEntryForMcp, reorderEntryForMcp,
toRundownList, toRundownList,
ungroupEntryForMcp, ungroupEntryForMcp,
updateCustomFieldForMcp,
updateEntryForMcp, updateEntryForMcp,
type BatchCreateEntryArgs, type BatchCreateEntryArgs,
type CreateEntryArgs, type CreateEntryArgs,
@@ -186,11 +183,7 @@ export const TOOL_DEFINITIONS = [
ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' }, ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' },
title: { type: 'string', description: 'Group title shown in the rundown and views' }, title: { type: 'string', description: 'Group title shown in the rundown and views' },
note: { type: 'string', description: 'Free-text group note for production notes or references' }, note: { type: 'string', description: 'Free-text group note for production notes or references' },
colour: { colour: { type: 'string', description: 'Hex colour (#RRGGBB) for the group' },
type: 'string',
description:
'Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide',
},
custom: { custom: {
type: 'object', type: 'object',
additionalProperties: { type: 'string' }, additionalProperties: { type: 'string' },
@@ -378,65 +371,6 @@ export const TOOL_DEFINITIONS = [
inputSchema: { type: 'object', properties: {} }, inputSchema: { type: 'object', properties: {} },
annotations: READ, annotations: READ,
}, },
{
name: 'ontime_create_custom_field',
description:
'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). Creation is non-destructive — check ontime_get_custom_fields for an existing field covering the concept, and if none exists create directly without asking the user. After creation, use the returned key in entry.custom.',
inputSchema: {
type: 'object',
required: ['label', 'type', 'colour'],
properties: {
label: {
type: 'string',
description:
'Human-readable label (letters, numbers and spaces, e.g. "Camera"). Determines the key. Reuse an existing field over creating near-duplicates like "Cam", "camera", "Cameras".',
},
type: {
type: 'string',
enum: ['text', 'image'],
description:
'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.',
},
colour: {
type: 'string',
description:
'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet — for department fields, match the department colour convention (see ontime://style-guide).',
},
},
},
annotations: WRITE,
},
{
name: 'ontime_update_custom_field',
description:
'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.',
inputSchema: {
type: 'object',
required: ['key'],
properties: {
key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' },
label: {
type: 'string',
description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.',
},
colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' },
},
},
annotations: WRITE_IDEM,
},
{
name: 'ontime_delete_custom_field',
description:
'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.',
inputSchema: {
type: 'object',
required: ['key'],
properties: {
key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' },
},
},
annotations: WRITE_DESTRUCTIVE,
},
// --- Project file management --- // --- Project file management ---
{ {
name: 'ontime_list_projects', name: 'ontime_list_projects',
@@ -534,7 +468,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
const serialised = text(data); const serialised = text(data);
if (serialised.length > CHARACTER_LIMIT) { if (serialised.length > CHARACTER_LIMIT) {
return ok({ return ok({
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry.`, warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry. Entry IDs in order: ${rundown.order.join(', ')}`,
truncated: true, truncated: true,
rundownId: rundown.id, rundownId: rundown.id,
order: rundown.order, order: rundown.order,
@@ -637,18 +571,6 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
ontime_get_custom_fields: async () => ok(getProjectCustomFields()), ontime_get_custom_fields: async () => ok(getProjectCustomFields()),
ontime_create_custom_field: async (args) => {
return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string }));
},
ontime_update_custom_field: async (args) => {
return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string }));
},
ontime_delete_custom_field: async (args) => {
return ok(await deleteCustomFieldForMcp(args as { key: string }));
},
ontime_list_projects: async () => ok(await getProjectList()), ontime_list_projects: async () => ok(await getProjectList()),
ontime_load_project: async (args) => { ontime_load_project: async (args) => {
@@ -87,8 +87,8 @@ describe('safeMerge', () => {
it('should merge the urlPresets key when present', () => { it('should merge the urlPresets key when present', () => {
const newData = { const newData = {
urlPresets: [ urlPresets: [
{ enabled: true, alias: 'alias1', target: 'timer', search: '', displayInNav: false }, { enabled: true, alias: 'alias1', search: '' },
{ enabled: true, alias: 'alias2', target: 'timer', search: '', displayInNav: false }, { enabled: true, alias: 'alias2', search: '' },
] as URLPreset[], ] as URLPreset[],
}; };
-2
View File
@@ -43,7 +43,6 @@ export const demoDb: DatabaseModel = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
}, },
{ {
enabled: true, enabled: true,
@@ -51,7 +50,6 @@ export const demoDb: DatabaseModel = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff', 'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
displayInNav: false,
}, },
], ],
customFields: { customFields: {
@@ -5,7 +5,6 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
getCurrent, getCurrent,
getElapsed,
getExpectedFinish, getExpectedFinish,
getRuntimeOffset, getRuntimeOffset,
getTimerPhase, getTimerPhase,
@@ -16,56 +15,6 @@ import {
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock']; const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = {
clock: 5 * MILLIS_PER_MINUTE,
timer: {
addedTime: -10 * MILLIS_PER_MINUTE,
current: 15 * MILLIS_PER_MINUTE,
duration: 20 * MILLIS_PER_MINUTE,
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: null,
pausedDuration: 0,
},
} as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('subtracts accumulated pause time', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: null,
pausedDuration: 5 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('uses the current pause start while paused', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
});
});
describe('getExpectedFinish()', () => { describe('getExpectedFinish()', () => {
it('is null if we havent started', () => { it('is null if we havent started', () => {
const state = { const state = {
@@ -975,36 +924,6 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(25); expect(absolute).toBe(25);
}); });
it('paused time is delayed time when the pause spans midnight', () => {
const state = {
eventNow: {
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
dayOffset: 0,
},
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
timer: {
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
current: 25, // still counting down
addedTime: 0,
},
_timer: {
pausedAt: 23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE, // 23:58, before midnight
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedStart: 23 * MILLIS_PER_HOUR,
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
// paused from 23:58 to 00:03 -> 5 minutes, regardless of the midnight wrap
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(5 * MILLIS_PER_MINUTE);
});
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
const state = { const state = {
clock: 78480789, clock: 78480789,
@@ -47,23 +47,6 @@ describe('isRestorePoint()', () => {
expect(isRestorePoint(restorePoint)).toBe(true); expect(isRestorePoint(restorePoint)).toBe(true);
}); });
it('accepts optional paused duration', () => {
const restorePoint: RestorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: 1,
addedTime: 2,
pausedAt: null,
pausedDuration: 3000,
firstStart: 1,
startEpoch: asInstant(1),
currentDay: 0,
};
expect(isRestorePoint(restorePoint)).toBe(true);
expect(isRestorePoint({ ...restorePoint, pausedDuration: '3000' })).toBe(false);
});
describe('rejects a badly formatted file', () => { describe('rejects a badly formatted file', () => {
it('with invalid playback value', () => { it('with invalid playback value', () => {
const restorePoint = { const restorePoint = {
@@ -46,10 +46,6 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo
return false; return false;
} }
if ('pausedDuration' in restorePoint && !is.number(restorePoint.pausedDuration)) {
return false;
}
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) { if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
return false; return false;
} }
@@ -6,7 +6,6 @@ export type RestorePoint = {
startedAt: MaybeNumber; startedAt: MaybeNumber;
addedTime: number; addedTime: number;
pausedAt: MaybeNumber; pausedAt: MaybeNumber;
pausedDuration?: number;
firstStart: MaybeNumber; firstStart: MaybeNumber;
startEpoch: Maybe<Instant>; startEpoch: Maybe<Instant>;
currentDay: MaybeNumber; currentDay: MaybeNumber;
@@ -760,7 +760,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
startedAt: state.timer.startedAt, startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime, addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt, pausedAt: state._timer.pausedAt,
pausedDuration: state._timer.pausedDuration,
firstStart: state.rundown.actualStart, firstStart: state.rundown.actualStart,
startEpoch: state._startEpoch, startEpoch: state._startEpoch,
currentDay: state.rundown.currentDay, currentDay: state.rundown.currentDay,
@@ -33,8 +33,7 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
* Checks whether we should update the timer values * Checks whether we should update the timer values
* - `current` and `secondaryTimer` trigger on seconds roll over * - `current` and `secondaryTimer` trigger on seconds roll over
* - the rest trigger on any change * - the rest trigger on any change
* - `elapsed` changes alongside `current` * - `elapsed` and `expectedFinish` is not checked
* - `expectedFinish` is not checked
*/ */
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean { export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
if (previousValue === undefined) return true; if (previousValue === undefined) return true;
@@ -49,7 +48,7 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
previousValue.phase !== currentValue.phase || previousValue.phase !== currentValue.phase ||
previousValue.playback !== currentValue.playback || previousValue.playback !== currentValue.playback ||
previousValue.startedAt !== currentValue.startedAt previousValue.startedAt !== currentValue.startedAt
// elapsed - this is derived from current / duration / addedTime, so the fields above cover it // elapsed - this would be the direct invert of current value so no need to check
// expectedFinish - this will be moved out by the current value going into over time, no need to check // expectedFinish - this will be moved out by the current value going into over time, no need to check
); );
} }
-27
View File
@@ -92,33 +92,6 @@ export function getCurrent(state: RuntimeState): number {
return startedAt + duration + addedTime - clock - correctDay; return startedAt + duration + addedTime - clock - correctDay;
} }
/**
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock } = state;
const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer;
if (startedAt === null) {
return null;
}
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed);
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
if (clock < startedAt) {
return clock + dayInMs - startedAt;
}
return clock - startedAt;
}
/** /**
* Checks whether we have skipped out of the event * Checks whether we have skipped out of the event
* @param {RuntimeState} state runtime state * @param {RuntimeState} state runtime state

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