Files
ontime/apps/spec/finder.md
T
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

18 KiB
Raw Blame History

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, Rundown Studio and Cuez 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)

  1. 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.
  2. 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.
  3. 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.
  4. Highlight the matched substring and show the matching field's text.
  5. 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.
  6. 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)

  1. 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.
  2. Gate searchable fields through getCuesheetColumnAccessPolicy().canRead(key) (§1.4). Non-negotiable.
  3. 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.
  4. 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.

  • 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 12 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.