Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente beb0a1394d accept bearer token 2026-08-11 20:52:54 +02:00
10 changed files with 218 additions and 531 deletions
@@ -8,8 +8,10 @@ export default memo(FinderPlacement);
function FinderPlacement() {
const [isOpen, handler] = useDisclosure();
// 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 }]]);
useHotkeys([
['mod + f', handler.toggle, { preventDefault: true }],
['Escape', handler.close, { preventDefault: true }],
]);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,7 +312,6 @@ 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} />
+23 -54
View File
@@ -1,11 +1,11 @@
import { useDebouncedCallback } from '@mantine/hooks';
import { EntryId, MaybeString, SupportedEntry } from 'ontime-types';
import { KeyboardEvent, PointerEvent, useEffect, useRef, useState } from 'react';
import { SupportedEntry } from 'ontime-types';
import { KeyboardEvent, 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, { FilterableEntry } from './useFinder';
import useFinder from './useFinder';
import style from './Finder.module.scss';
@@ -16,72 +16,43 @@ interface FinderProps {
export default function Finder({ isOpen, onClose }: FinderProps) {
const { find, select, results, error } = useFinder();
const [selectedId, setSelectedId] = useState<MaybeString>(null);
const activeRef = useRef<HTMLLIElement>(null);
const lastPointer = useRef({ x: -1, y: -1 });
const [selected, setSelected] = useState(0);
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') {
setSelectedId(results[(activeIndex + 1) % results.length].id);
setSelected((prev) => (prev + 1) % results.length);
}
if (event.key === 'ArrowUp') {
setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
setSelected((prev) => (prev - 1 + results.length) % results.length);
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
submit(activeEntry);
submit();
}
};
const submit = (entry: FilterableEntry | undefined) => {
if (!entry) {
return;
}
select(entry);
const submit = () => {
const selectedEvent = results[selected];
select(selectedEvent);
onClose();
};
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;
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);
}
}
lastPointer.current = { x: event.clientX, y: event.clientY };
setSelectedId(id);
};
return (
@@ -93,24 +64,22 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
bodyElements={
<div onKeyDown={navigate}>
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
<ul className={style.scrollContainer}>
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
{error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((entry) => {
const isSelected = activeEntry?.id === entry.id;
results.map((entry, index) => {
const isSelected = selected === index;
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}
onClick={() => submit(entry)}
onPointerMove={(event) => handlePointerMove(event, entry.id)}
data-index={index}
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': entry.colour }}>
@@ -35,7 +35,7 @@ type FilterableMilestone = {
parent: MaybeString;
};
export type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() {
const { data, rundownId } = useFlatRundown();
@@ -90,6 +90,10 @@ 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[] = [];
-21
View File
@@ -1,21 +0,0 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
/**
* Wraps the app authenticate middleware with support for the Authorization header.
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
* rather than cookies or query params; any other request falls through to the
* app middleware, keeping the behaviour of the shared middleware untouched.
*/
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
if (hasPassword) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
return next();
}
}
return fallback(req, res, next);
};
}
+1 -2
View File
@@ -13,7 +13,6 @@ import { socket } from './adapters/WebsocketAdapter.js';
// Import Routers
import { appRouter } from './api-data/index.js';
import { integrationRouter } from './api-integration/integration.router.js';
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
import { mcpRouter } from './api-mcp/mcp.router.js';
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
// Services
@@ -102,7 +101,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
// serve static external files
app.use(
@@ -1,6 +1,35 @@
import { describe, expect, it } from 'vitest';
import type { IncomingMessage } from 'node:http';
import { isPublicAssetRequest } from '../authenticate.js';
import type { NextFunction, Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/session/session.service.js', () => ({
hasPassword: true,
hashedPassword: 'valid-token',
}));
import {
authenticateSocket,
isPublicAssetRequest,
makeAuthenticateMiddleware,
} from '../authenticate.js';
function makeResponse() {
return {
redirect: vi.fn(),
send: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response;
}
function makeHeadersWithFailingAuthorization(cookie?: string) {
return {
cookie,
get authorization(): never {
throw new Error('Authorization header should not be read');
},
};
}
describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => {
@@ -18,3 +47,102 @@ describe('isPublicAssetRequest()', () => {
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
});
});
describe('bearer authentication', () => {
const next = vi.fn() as NextFunction;
beforeEach(() => {
next.mockClear();
});
it('prioritises cookie authentication for API requests', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
query: {},
} as unknown as Request;
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for redirecting routes', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
originalUrl: '/external/image.png',
query: {},
} as unknown as Request;
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for WebSocket handshakes', () => {
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('authenticates API requests with a bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
it('authenticates redirecting routes with a bearer token', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
originalUrl: '/stage/external/image.png',
query: {},
} as unknown as Request;
const res = makeResponse();
authenticateAndRedirect(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.redirect).not.toHaveBeenCalled();
});
it('authenticates WebSocket handshakes with a bearer token', () => {
const req = {
headers: { authorization: 'Bearer valid-token' },
} as IncomingMessage;
authenticateSocket({} as never, req, next);
expect(next).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith();
});
it('rejects an invalid bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer invalid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
});
+55 -48
View File
@@ -77,17 +77,16 @@ export function makeAuthenticateMiddleware(prefix: string) {
const loginRedirectBase = `${prefix}/login?redirect=`;
function authenticate(req: Request, res: Response, next: NextFunction) {
if (req.query.token) {
if (req.query.token === hashedPassword) {
return next();
}
if (getTokenFromCookies(req.cookies) === hashedPassword) {
return next();
}
if (req.cookies?.token) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
if (tokenFromCookie === hashedPassword) {
return next();
}
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
}
if (getTokenFromParams(req.query) === hashedPassword) {
return next();
}
res.status(401).send('Unauthorized');
@@ -105,17 +104,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
return next();
}
// we expect the token to be in the cookies
if (req.cookies?.token) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
if (tokenFromCookie === hashedPassword) {
return next();
}
if (getTokenFromCookies(req.cookies) === hashedPassword) {
return next();
}
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
}
// we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests
if (req.query.token === hashedPassword) {
if (getTokenFromParams(req.query) === hashedPassword) {
if (hashedPassword !== undefined) {
setSessionCookie(res, hashedPassword, prefix);
}
@@ -136,33 +135,16 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
return next();
}
// check if the token is in the cookie
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token) {
const token = getTokenFromCookie(cookies.token);
if (token === hashedPassword) {
return next();
}
}
}
// check if token is in the params - simple string check first
const urlString = req.url || '';
if (urlString.includes(`token=${hashedPassword}`)) {
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
return next();
}
// fallback to full URL parsing for other formats
try {
const url = new URL(urlString, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (token === hashedPassword) {
return next();
}
} catch (_) {
// ignore URL parsing errors
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
}
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
return next();
}
return next(new Error('Unauthorized'));
@@ -181,19 +163,18 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
});
}
/**
* When calling this function we already know a cookie called 'token' exists
* And want to extract its value
*/
function getTokenFromCookie(cookieContents: string): string | undefined {
// Fast path: check if the hashed password is directly in the cookie string
// This avoids JSON parsing for the common case
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
if (typeof cookieContents !== 'string') {
return undefined;
}
// Fast path: avoid JSON parsing when the expected token can be found directly
const cookieTokenString = '"token":"' + hashedPassword + '}"';
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
return hashedPassword;
}
// Fallback to JSON parsing for other cases or validation
try {
const cookie = JSON.parse(cookieContents);
if (cookie && typeof cookie.token === 'string') {
@@ -203,3 +184,29 @@ function getTokenFromCookie(cookieContents: string): string | undefined {
// no error handling to do here
}
}
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
if (authorization?.startsWith('Bearer ')) {
return authorization.slice(7);
}
}
function getTokenFromParams(
params: string | Record<string, unknown> | undefined,
host?: string,
): string | undefined {
if (typeof params !== 'string') {
return typeof params?.token === 'string' ? params.token : undefined;
}
// Fast path for WebSocket URLs
if (params.includes(`token=${hashedPassword}`)) {
return hashedPassword;
}
try {
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
} catch (_) {
return undefined;
}
}
-314
View File
@@ -1,314 +0,0 @@
# 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,92 +233,6 @@ 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();