Compare commits

...

6 Commits

Author SHA1 Message Date
Claude cabfc592df fix(finder): correct result selection and dismissal
Track the highlighted result by entry ID rather than by list index.
The index was never reset when results changed, so a background
refetch that shrank the list left it pointing past the end and
selecting threw on an undefined entry. Resolving by ID falls back to
the first result instead, and keeps the user's position across
rundown edits.

Submit the entry belonging to the clicked row instead of whichever
row was highlighted. The two only agreed because a mousemove usually
precedes a click, so touch input navigated to the wrong entry.

Ignore pointer moves that do not change the cursor position: scrolling
the list under a stationary pointer fires a move event which pulled
the selection away from the keyboard cursor. Keep the highlighted row
scrolled into view while navigating.

Close the finder on the search shortcut. Mantine ignores hotkeys while
an input is focused, so the global toggle could not close the modal
once the user was typing. The global Escape handler is dropped: the
dialog already dismisses on Escape, and registering it document wide
conflicts with inline field editing.

Drop the bounds check in the index search, which compared an event
ordinal against the count of all entries. The loop below it already
returns no results when no event carries that index.

Add e2e coverage for clicking a result, picking one with the keyboard,
and closing with the shortcut. This needs the finder rows and the
rundown event row to expose test ids and selection state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
2026-08-17 14:52:49 +00:00
Claude 1506ab76ed docs(spec): correct finder test coverage claim
The review stated the finder had no e2e coverage and that
209-rundown-shortcuts.spec.ts never opened it. Both are wrong: a
"Find in rundown" smoke test opens the modal and closes it with
Escape. Describe the coverage that exists and what it leaves unguarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
2026-08-17 14:52:35 +00:00
Claude d6fe8305fb docs(spec): add finder review and expansion roadmap
Reviews the existing rundown Finder and sets out a phased roadmap.

Findings:
- bare queries search title only; note and custom fields are never
  searched, making custom field data unfindable
- selected index is never reset when results change, so a shrinking
  result list can dereference undefined
- row clicks read selection from state rather than the clicked element,
  so touch input navigates to the wrong entry
- no visible affordance anywhere, leaving the feature unreachable on
  touch devices where no keyboard is available
- cuesheet already registers a scroll handler for finder jumps but the
  Finder is never mounted there

Roadmap covers correctness, search depth, visibility and polish,
cuesheet reach, and a find-and-replace expansion. Records the
constraints that shape the work: cuesheet URL preset column permissions,
index memoisation on rundown revision, and the absence of any undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
2026-08-17 11:31:13 +00:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
11 changed files with 489 additions and 38 deletions
@@ -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>
)}
@@ -8,10 +8,8 @@ export default memo(FinderPlacement);
function FinderPlacement() {
const [isOpen, handler] = useDisclosure();
useHotkeys([
['mod + f', handler.toggle, { preventDefault: true }],
['Escape', handler.close, { preventDefault: true }],
]);
// the finder handles its own dismissal: Escape is handled by the dialog, mod + f by the search input
useHotkeys([['mod + f', handler.toggle, { preventDefault: true }]]);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,6 +312,7 @@ export default function RundownEvent({
onClick={handleFocusClick}
onContextMenu={onContextMenu}
data-testid='rundown-event'
data-selected={isSelected}
{...(isPlaying ? { 'data-running': true } : {})}
>
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
+54 -23
View File
@@ -1,11 +1,11 @@
import { useDebouncedCallback } from '@mantine/hooks';
import { SupportedEntry } from 'ontime-types';
import { KeyboardEvent, useState } from 'react';
import { EntryId, MaybeString, SupportedEntry } from 'ontime-types';
import { KeyboardEvent, PointerEvent, useEffect, useRef, useState } from 'react';
import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal';
import useFinder from './useFinder';
import useFinder, { FilterableEntry } from './useFinder';
import style from './Finder.module.scss';
@@ -16,43 +16,72 @@ interface FinderProps {
export default function Finder({ isOpen, onClose }: FinderProps) {
const { find, select, results, error } = useFinder();
const [selected, setSelected] = useState(0);
const [selectedId, setSelectedId] = useState<MaybeString>(null);
const activeRef = useRef<HTMLLIElement>(null);
const lastPointer = useRef({ x: -1, y: -1 });
const debouncedFind = useDebouncedCallback(find, 100);
/**
* We track the selection by ID so that it survives the result list changing under us:
* an entry that no longer exists falls back to the first result instead of dangling past the end
*/
const activeIndex = Math.max(
0,
results.findIndex((entry) => entry.id === selectedId),
);
const activeEntry = results.at(activeIndex);
/** keep the highlighted entry in view while navigating with the keyboard */
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' });
}, [activeEntry?.id]);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
/**
* Mantine ignores hotkeys while an input is focused, so the global toggle
* cannot close the finder once the user is typing
*/
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
onClose();
return;
}
// all operations need results
if (results.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
setSelected((prev) => (prev + 1) % results.length);
setSelectedId(results[(activeIndex + 1) % results.length].id);
}
if (event.key === 'ArrowUp') {
setSelected((prev) => (prev - 1 + results.length) % results.length);
setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
submit();
submit(activeEntry);
}
};
const submit = () => {
const selectedEvent = results[selected];
select(selectedEvent);
const submit = (entry: FilterableEntry | undefined) => {
if (!entry) {
return;
}
select(entry);
onClose();
};
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const li = target.closest('li');
if (li) {
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
const handlePointerMove = (event: PointerEvent<HTMLLIElement>, id: EntryId) => {
// scrolling the list under a stationary cursor also fires a move event, which would
// pull the selection away from wherever the keyboard navigation left it
if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y) {
return;
}
lastPointer.current = { x: event.clientX, y: event.clientY };
setSelectedId(id);
};
return (
@@ -64,22 +93,24 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
bodyElements={
<div onKeyDown={navigate}>
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
<ul className={style.scrollContainer}>
{error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((entry, index) => {
const isSelected = selected === index;
results.map((entry) => {
const isSelected = activeEntry?.id === entry.id;
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = 'cue' in entry ? entry.cue : '';
return (
<li
key={entry.id}
ref={isSelected ? activeRef : undefined}
className={style.entry}
data-testid='finder-result'
data-selected={isSelected}
data-index={index}
onClick={submit}
onClick={() => submit(entry)}
onPointerMove={(event) => handlePointerMove(event, entry.id)}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': entry.colour }}>
@@ -35,7 +35,7 @@ type FilterableMilestone = {
parent: MaybeString;
};
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() {
const { data, rundownId } = useFlatRundown();
@@ -90,10 +90,6 @@ export default function useFinder() {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
+13 -1
View File
@@ -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'),
},
],
},
{
+314
View File
@@ -0,0 +1,314 @@
# 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
No unit or component tests, and `useSelectAndRevealEntry` is untested too. E2E coverage is a single
smoke test — `Find in rundown` in `e2e/tests/features/209-rundown-shortcuts.spec.ts` opens the modal
with `mod+F` and closes it with `Escape`. Nothing exercises searching, selecting, or navigating
results, so every behaviour described above is unguarded.
---
## 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.52 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 *(~34 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.52d)``3 (1.5d)``4 (1d)``5 (34d)`. 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.
@@ -233,6 +233,92 @@ test('Find in rundown', async ({ page }) => {
await expect(page.getByPlaceholder('Search...')).toBeHidden();
});
test('Close finder with the search shortcut', async ({ page }) => {
await page.goto('/rundown');
await expect(page.getByTestId('panel-rundown')).toBeVisible();
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
// the shortcut has to close the finder while the caret is in the search field
await page.getByPlaceholder('Search...').press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeHidden();
});
test('Finder navigates to the clicked result', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// create three events which all match the same search
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('finder one');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-2').getByTestId('entry__title').fill('finder two');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-3').getByTestId('entry__title').fill('finder three');
await page.getByTestId('entry-3').getByTestId('entry__title').press('Enter');
await page.keyboard.press('ControlOrMeta+f');
await page.getByPlaceholder('Search...').fill('finder');
await expect(page.getByTestId('finder-result')).toHaveCount(3);
/**
* Dispatch the click without moving the pointer first, which is what a touch device does.
* The finder used to submit whichever row was highlighted rather than the one being clicked.
*/
await page.getByTestId('finder-result').nth(2).dispatchEvent('click');
await expect(page.getByPlaceholder('Search...')).toBeHidden();
await expect(page.getByTestId('entry-3').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true');
await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'false');
});
test('Finder navigates to the result picked with the keyboard', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// create two events which both match the same search
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('finder one');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByTestId('entry-2').getByTestId('entry__title').fill('finder two');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
await page.keyboard.press('ControlOrMeta+f');
await page.getByPlaceholder('Search...').fill('finder');
await expect(page.getByTestId('finder-result')).toHaveCount(2);
// the first result is highlighted on open, so one step down lands on the second
await page.getByPlaceholder('Search...').press('ArrowDown');
await expect(page.getByTestId('finder-result').nth(1)).toHaveAttribute('data-selected', 'true');
await page.getByPlaceholder('Search...').press('Enter');
await expect(page.getByPlaceholder('Search...')).toBeHidden();
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true');
});
test('Open settings', async ({ page }) => {
await page.goto('/editor');
await expect(page.getByTestId('editor-container')).toBeVisible();