mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6fe8305fb | |||
| c6eccec30e | |||
| 5220c2c374 | |||
| 4eeeb294f7 |
@@ -200,8 +200,7 @@ $card-padding: 2rem;
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: $zindex-backdrop;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.updateIndicator {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AppVersion.module.scss';
|
||||
|
||||
export default function AppVersion() {
|
||||
const { data, isError } = useAppVersion();
|
||||
|
||||
@@ -18,7 +20,12 @@ export default function AppVersion() {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={`Ontime ${appVersion}`}
|
||||
title={
|
||||
<>
|
||||
<span className={style.updateIndicator} aria-hidden='true' />
|
||||
{`Ontime ${appVersion}`}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
isOntimeCloud
|
||||
? `Version ${data.version} is available. Restart your stage to update.`
|
||||
@@ -26,7 +33,7 @@ export default function AppVersion() {
|
||||
}
|
||||
/>
|
||||
{!isOntimeCloud && (
|
||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||
)}
|
||||
</Panel.ListItem>
|
||||
);
|
||||
|
||||
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{data.pendingRestart && (
|
||||
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
||||
submenu: [
|
||||
{
|
||||
label: 'New project...',
|
||||
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
|
||||
click: () => redirectWindow('/editor?settings=project__create'),
|
||||
},
|
||||
{
|
||||
label: 'Load...',
|
||||
@@ -202,6 +202,18 @@ function makeSettingsMenu(redirectWindow) {
|
||||
label: 'View settings',
|
||||
click: () => redirectWindow('/editor?settings=settings__view'),
|
||||
},
|
||||
{
|
||||
label: 'Custom views',
|
||||
click: () => redirectWindow('/editor?settings=settings__custom-views'),
|
||||
},
|
||||
{
|
||||
label: 'MCP Server',
|
||||
click: () => redirectWindow('/editor?settings=settings__mcp'),
|
||||
},
|
||||
{
|
||||
label: 'Server port',
|
||||
click: () => redirectWindow('/editor?settings=settings__port'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import * as timeCore from '../../lib/time-core/timeCore.js';
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import {
|
||||
findDayOffset,
|
||||
@@ -54,12 +53,11 @@ describe('getElapsed()', () => {
|
||||
it('uses the current pause start while paused', () => {
|
||||
const state = {
|
||||
clock: 10 * MILLIS_PER_MINUTE,
|
||||
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
timer: {
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
pausedAt: 7 * MILLIS_PER_MINUTE,
|
||||
pausedDuration: 1 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
} as RuntimeState;
|
||||
@@ -977,40 +975,6 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(absolute).toBe(25);
|
||||
});
|
||||
|
||||
it('paused time is delayed time when the pause spans midnight', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
||||
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
|
||||
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
timer: {
|
||||
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
|
||||
current: 25, // still counting down
|
||||
addedTime: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: timeCore.toInstant(
|
||||
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
|
||||
(timeCore.now() - dayInMs) as Instant,
|
||||
), // 23:58, before midnight
|
||||
pausedDuration: 0,
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 23 * MILLIS_PER_HOUR,
|
||||
plannedStart: 23 * MILLIS_PER_HOUR,
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
|
||||
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
|
||||
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('offset doesnt exist if we havent started', () => {
|
||||
const state = {
|
||||
clock: 78480789,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import * as timeCore from '../lib/time-core/timeCore.js';
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
|
||||
/**
|
||||
@@ -97,18 +96,17 @@ export function getCurrent(state: RuntimeState): number {
|
||||
* Calculates active time elapsed since the timer started.
|
||||
*/
|
||||
export function getElapsed(state: RuntimeState): MaybeNumber {
|
||||
const { clock, _now } = state;
|
||||
const { clock } = state;
|
||||
const { startedAt } = state.timer;
|
||||
const { pausedDuration, pausedAt } = state._timer;
|
||||
const { pausedAt, pausedDuration } = state._timer;
|
||||
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
|
||||
|
||||
const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
|
||||
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
|
||||
const referenceClock = pausedAt ?? clock;
|
||||
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
|
||||
const activeElapsed = elapsedSinceStart - pausedDuration;
|
||||
|
||||
return Math.max(0, activeElapsed);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../runtimeState.js';
|
||||
|
||||
const baseState: RuntimeState = {
|
||||
clock: 0 as TimeOfDay,
|
||||
_now: 0 as Instant,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
eventFlag: null,
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
|
||||
playback: Playback.Pause,
|
||||
addedTime: 0,
|
||||
});
|
||||
expect(newState._timer.pausedAt).toEqual(newState._now);
|
||||
expect(newState._timer.pausedAt).toEqual(newState.clock);
|
||||
|
||||
success = pause();
|
||||
expect(success).toBe(false);
|
||||
@@ -248,59 +248,6 @@ describe('mutation on runtimeState', () => {
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
test('elapsed excludes a pause that spans midnight', async () => {
|
||||
clearState();
|
||||
// an event that runs over midnight (23:00 -> 01:00)
|
||||
const event = {
|
||||
...mockEvent,
|
||||
id: 'elapsed-pause-midnight',
|
||||
timeStart: 23 * MILLIS_PER_HOUR,
|
||||
timeEnd: 1 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
};
|
||||
const mockRundown = makeRundown({
|
||||
entries: { [event.id]: event },
|
||||
order: [event.id],
|
||||
});
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start before midnight
|
||||
vi.setSystemTime('jan 1 23:50');
|
||||
load(event, rundown, metadata);
|
||||
start();
|
||||
|
||||
// 8 minutes of active running before we pause
|
||||
vi.setSystemTime('jan 1 23:58');
|
||||
update();
|
||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
pause();
|
||||
|
||||
// elapsed is active time since start, so it must not advance while paused,
|
||||
// not even when the pause itself crosses midnight
|
||||
vi.setSystemTime('jan 2 00:01');
|
||||
update();
|
||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
|
||||
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
|
||||
vi.setSystemTime('jan 2 00:03');
|
||||
start();
|
||||
let state = getState();
|
||||
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
|
||||
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
|
||||
// and elapsed still reflects only the 8 active minutes
|
||||
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
|
||||
// 2 more active minutes after resume -> 10 minutes elapsed
|
||||
vi.setSystemTime('jan 2 00:05');
|
||||
update();
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime offset', async () => {
|
||||
|
||||
@@ -63,9 +63,7 @@ export type RuntimeState = {
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
||||
pausedAt: Maybe<Instant>;
|
||||
|
||||
/** Accumulate pause duration but dose not include the current pause */
|
||||
pausedAt: Maybe<TimeOfDay>;
|
||||
pausedDuration: number;
|
||||
secondaryTarget: Maybe<TimeOfDay>;
|
||||
hasFinished: boolean;
|
||||
@@ -78,12 +76,10 @@ export type RuntimeState = {
|
||||
_end: ExpectedMetadata;
|
||||
_startEpoch: Maybe<Instant>;
|
||||
_startDayOffset: Maybe<Day>;
|
||||
_now: Instant;
|
||||
};
|
||||
|
||||
const runtimeState: RuntimeState = {
|
||||
clock: timeCore.timeOfDayNow(),
|
||||
_now: timeCore.now(),
|
||||
groupNow: null,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
@@ -108,12 +104,6 @@ const runtimeState: RuntimeState = {
|
||||
_startDayOffset: null,
|
||||
};
|
||||
|
||||
/** set the current clock to ensure parity between _now and clock */
|
||||
function setClock(state: RuntimeState) {
|
||||
state._now = timeCore.now();
|
||||
state.clock = timeCore.toTimeOfDay(state._now);
|
||||
}
|
||||
|
||||
export function getState(): Readonly<RuntimeState> {
|
||||
// create a shallow copy of the state
|
||||
return {
|
||||
@@ -146,7 +136,7 @@ export function clearEventData() {
|
||||
runtimeState.rundown.selectedEventIndex = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
setClock(runtimeState);
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
@@ -179,7 +169,7 @@ export function clearState() {
|
||||
runtimeState._end = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
setClock(runtimeState);
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
@@ -432,12 +422,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
setClock(state);
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
|
||||
state.clock = now;
|
||||
state.timer.secondaryTimer = null;
|
||||
|
||||
// add paused time if it exists
|
||||
if (state._timer.pausedAt) {
|
||||
const timeToAdd = state._now - state._timer.pausedAt;
|
||||
const timeToAdd = state.clock - state._timer.pausedAt;
|
||||
state.timer.addedTime += timeToAdd;
|
||||
state._timer.pausedDuration += timeToAdd;
|
||||
state._timer.pausedAt = null;
|
||||
@@ -454,7 +447,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
if (state.rundown.actualStart === null) {
|
||||
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
||||
state.rundown.currentDay = state._startDayOffset;
|
||||
state._startEpoch = state._now;
|
||||
state._startEpoch = epoch;
|
||||
state.rundown.actualStart = state.clock;
|
||||
}
|
||||
|
||||
@@ -488,8 +481,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
|
||||
}
|
||||
|
||||
state.timer.playback = Playback.Pause;
|
||||
setClock(state);
|
||||
state._timer.pausedAt = state._now;
|
||||
state.clock = timeCore.timeOfDayNow();
|
||||
state._timer.pausedAt = state.clock;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -554,7 +547,9 @@ export type UpdateResult = {
|
||||
export function update(): UpdateResult {
|
||||
// 0. there are some things we always do
|
||||
const previousClock = runtimeState.clock;
|
||||
setClock(runtimeState); // we update the clock on every update call
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
runtimeState.clock = now; // we update the clock on every update call
|
||||
|
||||
// 1. is playback idle?
|
||||
if (!isPlaybackActive(runtimeState.timer.playback)) {
|
||||
@@ -563,13 +558,13 @@ export function update(): UpdateResult {
|
||||
|
||||
// calculate currentDay from epoch (days elapsed since playback was started)
|
||||
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
|
||||
}
|
||||
|
||||
// 2. are we waiting to roll?
|
||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock);
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
|
||||
return updateIfWaitingToRoll(clockHasCrossedMidnight);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# Finder — review and expansion roadmap
|
||||
|
||||
## Context
|
||||
|
||||
The Finder is a `mod+F` modal that searches the rundown and jumps to an entry. Four files, ~370
|
||||
lines, essentially untouched since introduction, no tests, mounted only in the rundown editor.
|
||||
|
||||
This document reviews the feature and sets out a roadmap. The framing question was a
|
||||
business-development one: is it worth investing in, and can it beat the competition?
|
||||
|
||||
Scope decided for the roadmap: **editor + cuesheet**, **navigation-only** (the Finder reveals
|
||||
entries, it does not act on them), and **results stay in rundown order** — no ranking.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `apps/client/src/views/editor/finder/Finder.tsx` | Modal, input, result list, footer hints |
|
||||
| `apps/client/src/views/editor/finder/useFinder.tsx` | Search logic |
|
||||
| `apps/client/src/views/editor/finder/Finder.module.scss` | Styling |
|
||||
| `apps/client/src/features/rundown/placements/FinderPlacement.tsx` | `mod+F` hotkey + mount |
|
||||
|
||||
---
|
||||
|
||||
## 1. Review findings
|
||||
|
||||
### 1.1 It doesn't search what people search for
|
||||
|
||||
A bare query is **title-only**. The two things an operator has in their head are the **cue** and
|
||||
something written in a **note** or **custom field**.
|
||||
|
||||
- Typing `Q12` finds nothing — you must know to type `cue Q12`.
|
||||
- `note` is never searched, on any entry type.
|
||||
- `custom` fields are never searched. This is the real indictment: Ontime invites teams to model
|
||||
their show in custom fields, then makes that data unfindable.
|
||||
- Milestones carry a `cue` in the type, but only their title is matched.
|
||||
- Prefixes are brittle: `INDEX 4` works (input is lowercased) but `index4` and `cue:Q12` don't, and
|
||||
any title beginning "cue " or "title " can't be found as typed.
|
||||
|
||||
### 1.2 Two live bugs
|
||||
|
||||
- **Stale selection.** `selected` is never reset when `results` changes — not on a new query, not on
|
||||
the background-refetch replay at `useFinder.tsx:228-236`. If the list shrinks, `results[selected]`
|
||||
is `undefined` and `select()` throws on `.id`.
|
||||
- **Click hits the wrong row.** `onClick={submit}` reads `selected` from state, not the clicked
|
||||
element. It works only because a `mousemove` normally precedes the click — touch input navigates to
|
||||
the wrong entry. That's precisely the backstage-tablet case.
|
||||
|
||||
Two smaller ones: `mod+F` doesn't close the modal (Mantine's `useHotkeys` ignores `INPUT`, so the
|
||||
toggle stops firing once focus is in the box), and the `index <n>` bound compares against the flat
|
||||
entry count instead of the event count.
|
||||
|
||||
### 1.3 Nobody can find the Finder
|
||||
|
||||
There is **no visible affordance anywhere**. The only discovery path is the shortcut cheat sheet in
|
||||
`EventEditorEmpty.tsx` — which you see only when nothing is selected. Neither `RundownHeader.tsx` nor
|
||||
the cuesheet toolbar has a search control.
|
||||
|
||||
On touch there is no keyboard, so `RundownHeaderMobile.tsx` having no search button means the Finder
|
||||
is **completely unreachable on mobile and tablet** — the devices where finding an event by scrolling
|
||||
is hardest.
|
||||
|
||||
### 1.4 Reach
|
||||
|
||||
Editor-only. But `apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx:160` already
|
||||
registers a scroll handler commented *"for explicit jumps (finder/keyboard)"* — the plumbing was
|
||||
anticipated and built, the Finder was never mounted there. Cheapest high-value win available.
|
||||
|
||||
**Constraint:** the cuesheet is `permission='operator'` and can be exposed via URL presets with
|
||||
per-column read permissions (`cuesheet.policies.ts`, `getCuesheetColumnAccessPolicy().canRead(key)`).
|
||||
Searching notes and custom fields there **must** filter through that policy, or a locked-down preset
|
||||
link leaks columns it was configured to hide. Data exposure, not a nicety.
|
||||
|
||||
### 1.5 Index source — a trap worth naming
|
||||
|
||||
`useFinder` re-implements the 1-based `eventIndex` walk that `common/utils/rundownMetadata.ts`
|
||||
already computes. Tempting to just reuse `useFlatRundownWithMetadata()` — **don't**: its memo depends
|
||||
on `selectedEventId`, so it recomputes on every event load during a show, and it spreads every entry.
|
||||
`useFlatRundown` has the same class of problem (memoised on the react-query object, replaced on every
|
||||
refetch). Build from raw `rundown.entries` + `flatOrder`, memoised on **`rundown.revision`**.
|
||||
|
||||
### 1.6 Tests
|
||||
|
||||
None — no unit, no component, no e2e. `useSelectAndRevealEntry` is untested too.
|
||||
`e2e/tests/features/209-rundown-shortcuts.spec.ts` never opens the Finder.
|
||||
|
||||
---
|
||||
|
||||
## 2. Business-development assessment
|
||||
|
||||
**A command palette alone is table stakes** — every modern tool has one. Shipping `mod+K` wins nothing.
|
||||
|
||||
**A runtime-aware, multi-surface finder is a real wedge.** [Shoflo](https://shoflo.tv/),
|
||||
[Rundown Studio](https://rundownstudio.app/) and [Cuez](https://cuez.app/script-rundown/) are cloud
|
||||
rundown *editors* — their search is document search over a document you're authoring. Ontime knows
|
||||
show state: what's loaded, what's past, the offset, what's flagged. A finder that marks the loaded
|
||||
event and shows scheduled time and delay per row is a different category of tool, and that data
|
||||
already exists in `RundownMetadata` and is currently discarded. Add that it works identically on the
|
||||
FOH editor and a backstage tablet, and it's a coherence story the single-web-app competitors can't tell.
|
||||
|
||||
**Honest counterweight: don't oversell this internally.** This is a retention and credibility feature,
|
||||
not an acquisition one. Nobody picks Ontime over Shoflo for a search box. What it does: removes a
|
||||
recurring "I can't find my event" friction that bites hardest at the worst moment, makes the
|
||||
custom-fields investment finally pay off, and makes Ontime feel professional to the power users who
|
||||
become advocates.
|
||||
|
||||
**And the biggest problems here are bugs, absent scope and invisibility — not missing sophistication.**
|
||||
Sequence accordingly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Roadmap
|
||||
|
||||
Estimates assume one developer familiar with the codebase.
|
||||
|
||||
### Phase 1 — Correctness *(~0.5 day)*
|
||||
|
||||
1. **Track `selectedId`, not `selectedIndex`** — derive the active row by id lookup, falling back to
|
||||
the first. Correct across updates, reordering and result changes; better than "reset to 0", which
|
||||
throws away the user's position on every refetch.
|
||||
2. **Pass the result to the click handler**; delete the `data-index` / `dataset.index` mechanism.
|
||||
3. **Ignore pointer-move events where the cursor hasn't moved** — otherwise arrow-key navigation
|
||||
scrolls the list under a stationary cursor, fires a move event, and yanks the selection back. The
|
||||
12-result cap hides this today; a longer list won't.
|
||||
4. **Scroll the active row into view** on arrow navigation.
|
||||
5. **`mod+F` closes** — keep the global hotkey for opening only (its INPUT-ignoring default is right
|
||||
there), handle `mod+F` locally on the input to close. Drop the global `Escape` handler in
|
||||
`FinderPlacement`; Base UI's `Dialog` already handles it, and `preventDefault: true` globally is a
|
||||
latent conflict with inline field editing.
|
||||
6. **Fix the `index <n>` bound** to use the event count.
|
||||
|
||||
### Phase 2 — Search depth *(~1.5–2 days)*
|
||||
|
||||
7. **Search cue + title + note + text custom fields** across events, groups and milestones on a bare
|
||||
query. Skip `image`-type custom fields. Cap indexed note length so one pasted script can't
|
||||
dominate memory.
|
||||
8. **Add filters** `note:`, `flag:`, `group:`, `<custom-field>:`, accepting both `cue:x` and `cue x`
|
||||
for every key so today's documented syntax keeps working. Drive the footer hint and the
|
||||
`EventEditorEmpty.tsx` cheat sheet from one filter-key constant so they can't drift.
|
||||
9. **Keep rundown order — no ranking.** Deterministic, simpler, and it preserves the early-exit scan
|
||||
(today's cap of 12 is a *scan* cap, not a sort cap; ranking would force scanning everything).
|
||||
*One honest consequence:* widening the searched fields while keeping position order means a
|
||||
distant cue match can be pushed off the list by nearer note matches. Two mitigations, both cheap
|
||||
— raise the cap and show "showing 12 of 47", and label which field matched so a note hit is
|
||||
obviously a note hit. If that still isn't enough in practice, group by matched field (cue block,
|
||||
then title, then note/custom) with rundown order inside each block — still fully deterministic.
|
||||
10. **Highlight the matched substring** and show the matching field's text.
|
||||
11. **Make results a pure derivation of (index, query)** — a controlled input removes the
|
||||
`useEffect`-replays-`lastSearchString` mechanism entirely, and with it the stale-index crash,
|
||||
rather than patching around it.
|
||||
12. **Unit tests** for the query parser (pattern: `features/rundown/__tests__/rundown.utils.test.ts`).
|
||||
Highest-value case: `index <n>` staying aligned with 1-based UI event indices when delays, groups
|
||||
and milestones interleave — the current code gets this right and a rewrite is likely to break it.
|
||||
|
||||
### Phase 3 — Visibility, UX and polish *(~1.5 days)*
|
||||
|
||||
This is the phase that changes how many people ever use the feature.
|
||||
|
||||
**Visibility**
|
||||
13. **Add a search control to `RundownHeader.tsx`** with the `mod+F` hint visible on it. Single
|
||||
biggest discoverability win.
|
||||
14. **Add one to `RundownHeaderMobile.tsx`** — mandatory, not optional: without a keyboard the
|
||||
feature currently does not exist on touch devices.
|
||||
15. **Add one to the cuesheet toolbar** when the Finder mounts there (Phase 4).
|
||||
|
||||
**UX**
|
||||
16. **Fix the empty state** — it shows "No results" before you've typed anything. Show the filter
|
||||
hints, or recent searches, on open.
|
||||
17. **Align with the app's other search box.** `SettingsSearch.tsx` has a leading `IoSearch` icon and
|
||||
a clear button; the Finder has neither. Two search boxes in one app should look like one idea.
|
||||
18. **Show result count** ("showing 12 of 47") instead of a silent cap.
|
||||
19. **Indicate entry type** — events, groups and milestones are visually identical today apart from
|
||||
the index showing `-`.
|
||||
20. **Show group breadcrumbs**, so hits inside collapsed groups are legible.
|
||||
21. **Preserve the last query on reopen**, selected so typing replaces it.
|
||||
22. **`Home` / `End` / `PageUp` / `PageDown`** in the result list, matching `useRundownKeyboard`.
|
||||
|
||||
**Polish**
|
||||
23. **Use `getAccessibleColour`** (`common/utils/styleUtils`) for the index badge. It currently sets
|
||||
`background: var(--color)` raw with fixed foreground text, so a light entry colour is unreadable.
|
||||
The cuesheet `EventRow`, `MilestoneRow` and `OperatorEvent` already do this correctly — the
|
||||
Finder is the odd one out.
|
||||
24. **Fix row text handling** — fixed `3rem` rows with no ellipsis on long titles, and `.cue` capped
|
||||
at `max-height: 1em`, which clips descenders.
|
||||
25. **Stop the `Go ⏎` label shifting layout** when it appears only on the selected row.
|
||||
26. **Give the modal a real header** instead of `title=''`, which renders an empty header area.
|
||||
27. **Reconsider the 100 ms debounce** — with a controlled input (item 11) it's unnecessary at these
|
||||
rundown sizes and just reads as lag.
|
||||
|
||||
### Phase 4 — Cuesheet *(~1 day)*
|
||||
|
||||
28. **Move the Finder out of `features/rundown/placements/`** — it stops being an editor view once it
|
||||
has two homes — and mount it in the cuesheet. Pass surface and permission explicitly from the
|
||||
mount site rather than inferring them.
|
||||
29. **Gate searchable fields through `getCuesheetColumnAccessPolicy().canRead(key)`** (§1.4).
|
||||
Non-negotiable.
|
||||
30. **Show runtime state in results** — loaded event badged, past entries dimmed, scheduled time and
|
||||
delay per row. Source at render time, not by rebuilding the index (§1.5). This is the
|
||||
differentiator from §2.
|
||||
31. **E2E spec** alongside `209-rundown-shortcuts.spec.ts`, covering both surfaces, with a regression
|
||||
lock for the click bug: click the third result *without* hovering the first two, assert the third
|
||||
entry is selected.
|
||||
|
||||
### Phase 5 — Find and replace *(~3–4 days)*
|
||||
|
||||
The strongest expansion, and a better business case than a command palette: renaming a sponsor,
|
||||
speaker or venue across a 200-entry rundown is a real recurring pain that the competitors'
|
||||
spreadsheet-shaped editors handle badly. It also makes custom fields markedly more valuable.
|
||||
|
||||
**It does not live inside the Finder modal.** The Finder is a fast, non-destructive jump box and
|
||||
Enter must stay safe. The codebase already has the right pattern for this:
|
||||
`renumber-cues-dialog/RenumberCuesDialog.tsx` — a bulk mutation with its own dialog, its own
|
||||
endpoint, and `useEventSelection.selectedEvents` as its scope. Find-and-replace should be its
|
||||
sibling, reachable from `RundownMenu.tsx` (which today holds only "Manage Rundowns…" and "Clear
|
||||
all") and optionally `mod+shift+F`.
|
||||
|
||||
**What the Finder actually contributes** is its matcher. That's the real expansion: Phase 2's
|
||||
parser/matcher must be built as a shared module rather than Finder-private code, so find, preview
|
||||
and replace all agree on what "matches" means.
|
||||
|
||||
**32. The server gap — this is the actual work.** `batchEditEntries` (`rundown.service.ts:166`)
|
||||
applies **one patch to many ids**. Find-and-replace needs **per-entry distinct values** — each
|
||||
entry's own title with its own substring swapped. Two options:
|
||||
- N × `putEditEntry`: N round trips, N revision bumps, N `notifyChanges` broadcasts. During a show
|
||||
that's N timer notifications for one user action. Not acceptable at 40 entries.
|
||||
- **A new endpoint taking `Array<PatchWithId<OntimeEntry>>` applied inside one
|
||||
`createTransaction`/`commit`** — one revision bump, one broadcast. Sits next to `batchEditEntries`
|
||||
and reuses the same machinery. Perhaps 40 lines, and it's the thing that makes everything below
|
||||
possible.
|
||||
|
||||
**33. Replaceable fields — deliberately narrow.**
|
||||
- *Safe:* `title`, `note`, text `custom` values. Free text, no structural meaning.
|
||||
- *Opt-in and validated:* `cue`. It's a numbering scheme, not free text — `cueUtils` has
|
||||
`getIncrement`/`getCueCandidate` and the Renumber dialog exists precisely because of that. The
|
||||
server also rejects an empty cue outright (`rundown.service.ts:177`), so a replace that empties one
|
||||
fails the whole batch.
|
||||
- *Never:* times, booleans, colours, enums, ids, `image`-type custom fields.
|
||||
|
||||
**34. Preview is mandatory, because there is no undo anywhere in this app.** I checked — the client
|
||||
has no undo stack of any kind. Find-and-replace would be the first feature that can silently alter
|
||||
dozens of entries. So: show every affected entry with before → after per field, with a per-match
|
||||
opt-out, before anything is written. Confirmation names the count and the field: *"Replace 'Q' with
|
||||
'CUE' in title on 23 entries?"*
|
||||
|
||||
**35. A single-step revert.** Because the atomic endpoint gives one revision per operation, capturing
|
||||
the prior values client-side makes "Undo replace" a second batch call. Not an undo history — one
|
||||
step, scoped to the operation, discarded on the next mutation. Cheap, honest, and it's the difference
|
||||
between a feature people trust and one they don't touch during a show.
|
||||
|
||||
**36. Scoping controls:** whole rundown vs. current selection (the `RenumberCuesDialog` precedent
|
||||
already reads `selectedEvents`); restrict to one field; case-sensitive toggle — the Finder is
|
||||
case-insensitive by design, so replace needs this as an explicit option; whole-word toggle.
|
||||
|
||||
**37. Guard concurrency.** Capture the rundown `revision` at preview time and reject the apply if it
|
||||
moved. Prevents replacing text the user never saw someone else write. Cheap.
|
||||
|
||||
**38. Editor only for v1.** The cuesheet is `permission='operator'` and its URL presets can be
|
||||
read-only or column-restricted; a correct implementation would have to gate every field through
|
||||
`getCuesheetColumnAccessPolicy().canWrite(key)`. *Finding* is safe to expose broadly; *replacing* is
|
||||
not. Warn when playback is running rather than blocking.
|
||||
|
||||
**39. No regex.** Live operators, no undo history, and one bad pattern destroys a rundown.
|
||||
Whole-word + case-sensitive covers the real cases — renaming a speaker, a sponsor, a venue.
|
||||
|
||||
Rough split: server endpoint ~0.5d, matcher extraction ~0.5d, dialog + preview ~1.5d, revert ~0.5d,
|
||||
tests ~1d.
|
||||
|
||||
### Not recommended
|
||||
|
||||
- **Ranking / fuzzy matching.** Rundown order is deterministic and simpler, and Ontime cues are short
|
||||
and numeric-ish (`1`, `1.5`, `12A`) where subsequence matching is pure noise — `12` would match
|
||||
`1`…`2` across half the rundown. Live operators need predictable results more than forgiving ones.
|
||||
- **A worker-side index or a server search endpoint.** The client holds the whole rundown; a linear
|
||||
scan over `flatOrder` is microseconds. Add a hard scan ceiling so a pathological spreadsheet import
|
||||
can't freeze the UI thread, and leave it there.
|
||||
- **Virtualising the result list.** A palette showing 400 rows isn't more useful than one showing 50;
|
||||
scanning is the bottleneck, not rendering. Cap and count instead.
|
||||
- **Implicit cross-rundown search.** Revealing an entry in another rundown means changing the
|
||||
*loaded* rundown — a destructive runtime action mid-show. Only the cuesheet has a non-destructive
|
||||
"viewed rundown" concept. If ever wanted, an opt-in `rundown:` filter there, never in the editor.
|
||||
- **The operator view and public viewers.** The operator view never calls `setScrollHandler`, so
|
||||
"go to entry" would silently do nothing until one is registered.
|
||||
|
||||
---
|
||||
|
||||
## 4. Verification
|
||||
|
||||
Each phase should land behind:
|
||||
|
||||
- **Unit tests** for the query parser and matcher — the highest-value case being `index <n>` staying
|
||||
aligned with 1-based UI event indices when delays, groups and milestones interleave.
|
||||
- **An e2e spec** opening `mod+F` in both the editor and the cuesheet, including a regression lock
|
||||
for the click bug (click the third result without hovering the first two).
|
||||
- **A manual pass** on a project that leans on custom fields, and one on a touch device — the mobile
|
||||
path is currently unreachable and needs checking by hand.
|
||||
|
||||
Two claims in this document are worth re-confirming before they drive a decision, since both are
|
||||
cheap to check and expensive to be wrong about: the cuesheet permission concern
|
||||
(`cuesheet.policies.ts`, `useTablePermissions.tsx`), and the "relevance not speed" assumption —
|
||||
count `flatOrder` length on the largest real project file available rather than trusting the estimate.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
|
||||
`1 (0.5d)` → `2 (1.5–2d)` → `3 (1.5d)` → `4 (1d)` → `5 (3–4d)`. Each independently shippable.
|
||||
|
||||
**If only one thing gets done: Phase 1.** If two: Phase 1 + Phase 3 — the bugs and the invisibility
|
||||
are what actually cost users today; the search-depth work matters most once people can find the box.
|
||||
|
||||
Phase 5 is the one with a real competitive argument, but it depends on Phase 2 shipping its matcher
|
||||
as a shared module rather than Finder-private code. That's a cheap constraint to honour up front and
|
||||
an expensive one to retrofit — worth deciding before Phase 2 starts, even if find-and-replace is
|
||||
months away.
|
||||
Reference in New Issue
Block a user