diff --git a/apps/spec/finder.md b/apps/spec/finder.md new file mode 100644 index 000000000..81d123bb7 --- /dev/null +++ b/apps/spec/finder.md @@ -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 ` 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 ` 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:`, `:`, 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 ` 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>` 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 ` 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.