mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-27 09:59:08 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6a4d5d89e | |||
| 1ddfe8a969 | |||
| b9683f00dc | |||
| 63fdc50c50 | |||
| 5acfc4a122 |
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Review Ontime changes for concrete correctness, architecture, testing, routing, security, and maintenance issues. For GitHub Copilot review.
|
||||
---
|
||||
|
||||
# Review Ontime changes
|
||||
|
||||
Review as a maintainer. Find material defects, not speculative advice, style preferences, or generic checklists. Approve when no material issue remains.
|
||||
|
||||
## Load relevant context
|
||||
|
||||
Read the full diff, PR description, linked issue, tests, and nearby owning code. Then load only applicable guides:
|
||||
|
||||
- Every non-trivial review: [change assessment](../../../docs/agent-guides/change-assessment.md)
|
||||
- Module placement, server layers, client state, shared packages: [architecture](../../../docs/agent-guides/architecture.md)
|
||||
- Tests or changed behaviour: [testing](../../../docs/agent-guides/testing.md)
|
||||
- Comments, abstractions, naming, complexity: [code quality](../../../docs/agent-guides/code-quality.md)
|
||||
- Authentication, external input, files, integrations, assets, secrets: [security](../../../docs/agent-guides/security.md)
|
||||
- Routes, URLs, websockets, authentication, cookies, presets, assets: [routing and cloud](../../../docs/agent-guides/routing-and-cloud.md)
|
||||
- Rundowns, timers, persistence, imports, cache, realtime: [domain invariants](../../../docs/agent-guides/domain-invariants.md)
|
||||
- Commands, imports, dependencies, formatting, CI claims: [workflow](../../../docs/agent-guides/workflow.md)
|
||||
|
||||
Check a guide and nearby canonical code before citing an Ontime convention. Skip unrelated guides.
|
||||
|
||||
## Review order
|
||||
|
||||
1. Establish intent and affected runtime surfaces.
|
||||
2. Read tests first. Identify claimed behaviour and coverage.
|
||||
3. Trace implementation, errors, and state transitions.
|
||||
4. Check correctness/data integrity, security, architecture/testability, cloud routing, lifecycle/performance, maintainability.
|
||||
5. Verify claimed checks. Never claim unobserved results.
|
||||
|
||||
Passing tests do not prove architecture, routing, comments, or error paths. Review changed behaviour only; include existing problems only when the diff worsens or relies on them.
|
||||
|
||||
## Findings
|
||||
|
||||
Report only concrete, actionable issues. Each finding: tight line range, direct defect, impact and trigger, smallest viable remedy when unclear.
|
||||
|
||||
- **P0 — Critical:** data loss, exploitable vulnerability, broadly broken production. Blocks merge.
|
||||
- **P1 — High:** likely correctness failure or major supported-deployment regression. Blocks merge.
|
||||
- **P2 — Medium:** real edge-case defect, architecture regression, missing business-rule test, stale comment, meaningful maintenance risk. Normally blocks merge.
|
||||
- **P3 — Low:** local improvement with limited impact. No subjective style or tool-managed formatting.
|
||||
|
||||
Order by priority. Prefer few high-confidence findings. No praise or checklist before findings. If none, say so and note verification gaps or residual risk.
|
||||
|
||||
Then give one concise PR-level value/risk/complexity assessment. Never repeat it per finding.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Ontime agent guide
|
||||
|
||||
Make the smallest maintainable change. Keep scope narrow. Inspect nearby code first. Reuse helpers and boundaries when semantics match.
|
||||
|
||||
## Load only relevant guides
|
||||
|
||||
- Commands, validation, formatting, imports, PRs: [workflow](docs/agent-guides/workflow.md).
|
||||
- Non-trivial planning, implementation, review: [change assessment](docs/agent-guides/change-assessment.md).
|
||||
- Module placement, server layers, client state, shared packages: [architecture](docs/agent-guides/architecture.md).
|
||||
- Tests or changed behaviour: [testing](docs/agent-guides/testing.md).
|
||||
- Comments, naming, abstractions, maintainability: [code quality](docs/agent-guides/code-quality.md).
|
||||
- Authentication, external input, files, integrations, assets, secrets: [security](docs/agent-guides/security.md).
|
||||
- Navigation, URLs, API paths, websockets, redirects, cookies, static assets: [routing and cloud](docs/agent-guides/routing-and-cloud.md).
|
||||
- Rundowns, timers, imports, persistence, cache, websockets: [domain invariants](docs/agent-guides/domain-invariants.md).
|
||||
|
||||
Load multiple guides when needed. Skip unrelated guides for mechanical work.
|
||||
|
||||
## Before handoff
|
||||
|
||||
- Check the final diff for scope, stale comments, temporary code, redundant tests, generated files.
|
||||
- If work reveals a missing, stable, reusable system, domain, or product invariant, update its owning guide. Exclude guesses, one-off bugs, and implementation details.
|
||||
- Follow [workflow verification](docs/agent-guides/workflow.md). Report only observed results.
|
||||
Binary file not shown.
@@ -1,18 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import Button from './Button';
|
||||
|
||||
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
|
||||
/** whether the option this button controls is currently on */
|
||||
pressed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A button which carries an on / off state.
|
||||
*
|
||||
* Keeps the pressed styling and the accessible state together, so that a toggle
|
||||
* cannot end up looking active without also announcing that it is.
|
||||
*/
|
||||
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
|
||||
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useState } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import ToggleButton from '../../common/components/buttons/ToggleButton';
|
||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
@@ -56,66 +55,72 @@ export default function Log() {
|
||||
<div className={cx([style.container, isExtracted && style.extracted])}>
|
||||
<Panel.InlineElements className={style.buttonBar}>
|
||||
<span className={style.filterLabel}>Filter by</span>
|
||||
<ToggleButton
|
||||
pressed={showUser}
|
||||
<Button
|
||||
variant={showUser ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showUser}
|
||||
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.User}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showClient}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showClient ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showClient}
|
||||
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
|
||||
onClick={() => setShowClient((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Client}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showServer}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showServer ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showServer}
|
||||
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
|
||||
onClick={() => setShowServer((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Server}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showPlayback}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showPlayback ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showPlayback}
|
||||
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
|
||||
onClick={() => setShowPlayback((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Playback}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showRx}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showRx ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showRx}
|
||||
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
|
||||
onClick={() => setShowRx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Rx}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showTx}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showTx ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showTx}
|
||||
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
|
||||
onClick={() => setShowTx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Tx}
|
||||
</ToggleButton>
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
|
||||
<IoClose /> Clear
|
||||
</Button>
|
||||
|
||||
@@ -8,16 +8,10 @@ export default memo(FinderPlacement);
|
||||
function FinderPlacement() {
|
||||
const [isOpen, handler] = useDisclosure();
|
||||
|
||||
/**
|
||||
* The empty tagsToIgnore is significant: by default the hook skips input elements,
|
||||
* which would make the shortcut dead while editing an entry.
|
||||
*
|
||||
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
|
||||
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
|
||||
* search again" rather than "close it". The finder selects its input instead, and
|
||||
* Escape closes.
|
||||
*/
|
||||
useHotkeys([['mod + f', handler.open, { 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} />
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
.error {
|
||||
padding-inline: 0.5rem;
|
||||
font-size: 1rem;
|
||||
// rows grow when a match is shown from a note or custom field
|
||||
min-height: 3rem;
|
||||
padding-block: 0.35rem;
|
||||
height: 3rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.entry[data-selected='true'] {
|
||||
@@ -21,47 +18,21 @@
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.more {
|
||||
padding-inline: 0.5rem;
|
||||
padding-block: 0.75rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $label-gray;
|
||||
border-top: 1px solid $gray-1000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.data {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'index cue'
|
||||
'index title'
|
||||
'index match';
|
||||
'index title';
|
||||
column-gap: 1rem;
|
||||
grid-template-rows: min-content 1fr;
|
||||
min-width: 0;
|
||||
|
||||
.index {
|
||||
grid-area: index;
|
||||
// background and text colour come from getAccessibleColour, which keeps the
|
||||
// number legible whatever colour the user gave the entry
|
||||
background-color: var(--color, $gray-1000);
|
||||
border-radius: 2px;
|
||||
padding-block: 0.25rem;
|
||||
width: 3.5rem;
|
||||
@@ -71,33 +42,14 @@
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cue {
|
||||
grid-area: cue;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $label-gray;
|
||||
max-height: 1em;
|
||||
min-height: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.match {
|
||||
grid-area: match;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.matchLabel {
|
||||
color: $ui-white;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,14 +63,13 @@
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.count {
|
||||
.filterHint {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.go {
|
||||
white-space: nowrap;
|
||||
padding-left: 1rem;
|
||||
.em {
|
||||
color: $ui-white;
|
||||
margin-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.hints {
|
||||
@@ -147,7 +98,7 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.count {
|
||||
.filterHint {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { MaybeString } from 'ontime-types';
|
||||
import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import { SupportedEntry } from 'ontime-types';
|
||||
import { KeyboardEvent, useState } from 'react';
|
||||
|
||||
import ToggleButton from '../../../common/components/buttons/ToggleButton';
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
import Kbd from '../../../common/components/kbd/Kbd';
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import useFinder, { FinderResult } from './useFinder';
|
||||
import useFinder from './useFinder';
|
||||
|
||||
import style from './Finder.module.scss';
|
||||
|
||||
@@ -16,76 +15,46 @@ interface FinderProps {
|
||||
}
|
||||
|
||||
export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<MaybeString>(null);
|
||||
const [selectedId, setSelectedId] = useState<MaybeString>(null);
|
||||
const { find, select, results, error } = useFinder();
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
/**
|
||||
* Keeps typing responsive while the list re-renders.
|
||||
* The search itself is cheap, rendering the results is what costs.
|
||||
*/
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const activeRef = useRef<HTMLLIElement>(null);
|
||||
|
||||
/**
|
||||
* 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 debouncedFind = useDebouncedCallback(find, 100);
|
||||
|
||||
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
// pressing the search shortcut again selects the query, ready to be replaced
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
|
||||
event.preventDefault();
|
||||
inputRef.current?.select();
|
||||
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: FinderResult | undefined) => {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
select(entry);
|
||||
const submit = () => {
|
||||
const selectedEvent = results[selected];
|
||||
select(selectedEvent);
|
||||
onClose();
|
||||
};
|
||||
|
||||
/** Scopes the search to a single field, or back to all fields when tapped again */
|
||||
const handleFilter = (filterKey: string) => {
|
||||
setFilter((previous) => (previous === filterKey ? null : filterKey));
|
||||
inputRef.current?.focus();
|
||||
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 hiddenResults = total - results.length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title=''
|
||||
@@ -94,68 +63,35 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
showBackdrop
|
||||
bodyElements={
|
||||
<div onKeyDown={navigate}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
height='large'
|
||||
fluid
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder='Search...'
|
||||
/>
|
||||
<div className={style.filters} data-testid='finder-filters'>
|
||||
<span className={style.filterLabel}>Filter by</span>
|
||||
{filters.map((option) => (
|
||||
<ToggleButton
|
||||
key={option.key}
|
||||
pressed={appliedFilter === option.key}
|
||||
size='small'
|
||||
onClick={() => handleFilter(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
<ul className={style.scrollContainer}>
|
||||
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
|
||||
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
|
||||
{error && <li className={style.error}>{error}</li>}
|
||||
{!error && results.length === 0 && <li className={style.empty}>No results</li>}
|
||||
{results.map((entry) => {
|
||||
const isSelected = activeEntry?.id === entry.id;
|
||||
// the title and cue are already on the row, a match anywhere else needs showing
|
||||
const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue';
|
||||
{results.length === 0 && <li className={style.empty}>No results</li>}
|
||||
{results.length > 0 &&
|
||||
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={() => setSelectedId(entry.id)}
|
||||
>
|
||||
<div className={style.data}>
|
||||
<div className={style.index} style={getAccessibleColour(entry.colour)}>
|
||||
{entry.eventIndex ?? '-'}
|
||||
</div>
|
||||
<div className={style.cue}>{entry.cue}</div>
|
||||
<div className={style.title}>{entry.title}</div>
|
||||
{showMatch && (
|
||||
<div className={style.match} data-testid='finder-result-match'>
|
||||
<span className={style.matchLabel}>{entry.match?.label}</span>
|
||||
{entry.match?.excerpt}
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className={style.entry}
|
||||
data-selected={isSelected}
|
||||
data-index={index}
|
||||
onClick={submit}
|
||||
>
|
||||
<div className={style.data}>
|
||||
<div className={style.index} style={{ '--color': entry.colour }}>
|
||||
{displayIndex}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <span className={style.go}>Go ⏎</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{hiddenResults > 0 && (
|
||||
<li className={style.more} data-testid='finder-more'>
|
||||
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} — keep typing to narrow the search
|
||||
</li>
|
||||
)}
|
||||
<div className={style.cue}>{displayCue}</div>
|
||||
<div className={style.title}>{entry.title}</div>
|
||||
</div>
|
||||
{isSelected && <span>Go ⏎</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@@ -176,11 +112,10 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className={style.count} data-testid='finder-count'>
|
||||
{hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
)}
|
||||
<div className={style.filterHint}>
|
||||
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
|
||||
<span className={style.em}>title</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { parseQuery, searchByIndex, searchByText } from './useFinder';
|
||||
|
||||
function makeEvent(id: string, overrides: Partial<OntimeEvent> = {}): OntimeEvent {
|
||||
return {
|
||||
type: SupportedEntry.Event,
|
||||
id,
|
||||
cue: '',
|
||||
title: '',
|
||||
note: '',
|
||||
colour: '#000000',
|
||||
custom: {},
|
||||
parent: null,
|
||||
...overrides,
|
||||
} as OntimeEvent;
|
||||
}
|
||||
|
||||
function makeGroup(id: string, overrides: Partial<OntimeGroup> = {}): OntimeGroup {
|
||||
return {
|
||||
type: SupportedEntry.Group,
|
||||
id,
|
||||
title: '',
|
||||
note: '',
|
||||
colour: '#000000',
|
||||
custom: {},
|
||||
...overrides,
|
||||
} as OntimeGroup;
|
||||
}
|
||||
|
||||
function makeMilestone(id: string, overrides: Partial<OntimeMilestone> = {}): OntimeMilestone {
|
||||
return {
|
||||
type: SupportedEntry.Milestone,
|
||||
id,
|
||||
cue: '',
|
||||
title: '',
|
||||
note: '',
|
||||
colour: '#000000',
|
||||
custom: {},
|
||||
parent: null,
|
||||
...overrides,
|
||||
} as OntimeMilestone;
|
||||
}
|
||||
|
||||
function makeDelay(id: string): OntimeDelay {
|
||||
return { type: SupportedEntry.Delay, id, duration: 1000, parent: null };
|
||||
}
|
||||
|
||||
describe('parseQuery()', () => {
|
||||
const filters = [
|
||||
{ key: 'cue', label: 'Cue' },
|
||||
{ key: 'Camera_Notes', label: 'Camera Notes' },
|
||||
];
|
||||
|
||||
it.each([
|
||||
['cue 12', { filterKey: 'cue', searchString: '12' }],
|
||||
['cue:12', { filterKey: 'cue', searchString: '12' }],
|
||||
['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }],
|
||||
])('parses the field prefix in %s', (searchValue, expected) => {
|
||||
expect(parseQuery(searchValue, filters)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('keeps an unprefixed query as a search across all fields', () => {
|
||||
expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' });
|
||||
});
|
||||
|
||||
it('recognises a filter before any search text has been entered', () => {
|
||||
expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByText()', () => {
|
||||
const customFields: CustomFields = {
|
||||
Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' },
|
||||
Slide: { type: 'image', label: 'Slide', colour: '#000000' },
|
||||
};
|
||||
|
||||
it('searches cue, title, note, and text custom fields in rundown order', () => {
|
||||
const data = [
|
||||
makeMilestone('milestone', { cue: 'needle' }),
|
||||
makeGroup('group', { title: 'needle' }),
|
||||
makeEvent('note', { note: 'find the needle here' }),
|
||||
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
|
||||
];
|
||||
|
||||
const outcome = searchByText(data, customFields, null, 'needle');
|
||||
|
||||
expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([
|
||||
{ id: 'milestone', field: 'cue' },
|
||||
{ id: 'group', field: 'title' },
|
||||
{ id: 'note', field: 'note' },
|
||||
{ id: 'custom', field: 'Camera_Notes' },
|
||||
]);
|
||||
expect(outcome.total).toBe(4);
|
||||
});
|
||||
|
||||
it('searches only the selected field', () => {
|
||||
const data = [
|
||||
makeEvent('title', { title: 'needle' }),
|
||||
makeEvent('note', { note: 'needle' }),
|
||||
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
|
||||
];
|
||||
|
||||
const outcome = searchByText(data, customFields, 'note', 'needle');
|
||||
|
||||
expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']);
|
||||
expect(outcome.total).toBe(1);
|
||||
});
|
||||
|
||||
it('reports the first matching field so the result can explain why it matched', () => {
|
||||
const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })];
|
||||
|
||||
const outcome = searchByText(data, customFields, null, 'needle');
|
||||
|
||||
expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' });
|
||||
});
|
||||
|
||||
it('does not search image custom fields', () => {
|
||||
const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })];
|
||||
|
||||
expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 });
|
||||
});
|
||||
|
||||
it('reports the full match count while limiting rendered results', () => {
|
||||
const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' }));
|
||||
|
||||
const outcome = searchByText(data, customFields, null, 'needle');
|
||||
|
||||
expect(outcome.results).toHaveLength(50);
|
||||
expect(outcome.total).toBe(51);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByIndex()', () => {
|
||||
it('counts only events while preserving the flat rundown position', () => {
|
||||
const data = [
|
||||
makeGroup('group'),
|
||||
makeDelay('delay'),
|
||||
makeEvent('first'),
|
||||
makeMilestone('milestone'),
|
||||
makeEvent('second'),
|
||||
];
|
||||
|
||||
const outcome = searchByIndex(data, '2');
|
||||
|
||||
expect(outcome.results).toHaveLength(1);
|
||||
expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 });
|
||||
expect(outcome.total).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => {
|
||||
expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({
|
||||
results: [],
|
||||
error: 'Invalid index',
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no result when the event index is beyond the rundown', () => {
|
||||
expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1,259 +1,239 @@
|
||||
import {
|
||||
CustomFields,
|
||||
EntryId,
|
||||
MaybeNumber,
|
||||
MaybeString,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
} from 'ontime-types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
||||
|
||||
/** How many results we render, the total number of matches is reported separately */
|
||||
const maxResults = 50;
|
||||
/** Notes can hold a whole script, we only show enough to explain the match */
|
||||
const excerptPadding = 40;
|
||||
const maxResults = 12;
|
||||
|
||||
const indexFilter = 'index';
|
||||
|
||||
/** Everything except delays, which carry no text to search */
|
||||
type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
|
||||
|
||||
type FinderFilter = { key: string; label: string };
|
||||
|
||||
/**
|
||||
* Offered to the user as filter badges. Index is a positional lookup rather than a
|
||||
* text field, so it is handled separately from the fields a search runs over.
|
||||
*/
|
||||
const staticFilters: FinderFilter[] = [
|
||||
{ key: indexFilter, label: 'Index' },
|
||||
{ key: 'cue', label: 'Cue' },
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'note', label: 'Note' },
|
||||
];
|
||||
|
||||
/** Why an entry matched, so the UI can show the user */
|
||||
type FinderMatch = { key: string; label: string; excerpt: string };
|
||||
|
||||
export type FinderResult = {
|
||||
type FilterableGroup = {
|
||||
type: SupportedEntry.Group;
|
||||
id: EntryId;
|
||||
/** position in the flat rundown, which is how the rundown reveals an entry */
|
||||
index: number;
|
||||
/** 1-based position among events, null for groups and milestones */
|
||||
eventIndex: MaybeNumber;
|
||||
title: string;
|
||||
/** groups have no cue */
|
||||
colour: string;
|
||||
};
|
||||
|
||||
type FilterableEvent = {
|
||||
type: SupportedEntry.Event;
|
||||
id: EntryId;
|
||||
index: number;
|
||||
eventIndex: number;
|
||||
title: string;
|
||||
cue: string;
|
||||
colour: string;
|
||||
parent: MaybeString;
|
||||
/** absent when the entry was found by index rather than by matching text */
|
||||
match: FinderMatch | null;
|
||||
};
|
||||
|
||||
type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
|
||||
type FilterableMilestone = {
|
||||
type: SupportedEntry.Milestone;
|
||||
id: EntryId;
|
||||
index: number;
|
||||
title: string;
|
||||
cue: string;
|
||||
colour: string;
|
||||
parent: MaybeString;
|
||||
};
|
||||
|
||||
const noResults: SearchOutcome = { results: [], error: null, total: 0 };
|
||||
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
|
||||
|
||||
/** Groups are the only searchable entry with neither a cue nor a parent */
|
||||
function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
|
||||
return {
|
||||
id: entry.id,
|
||||
index,
|
||||
eventIndex,
|
||||
title: entry.title,
|
||||
cue: 'cue' in entry ? entry.cue : '',
|
||||
colour: entry.colour,
|
||||
parent: 'parent' in entry ? entry.parent : null,
|
||||
match,
|
||||
} satisfies FinderResult;
|
||||
}
|
||||
|
||||
/** Shows enough of a long value for the user to see why it matched */
|
||||
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
|
||||
const start = Math.max(0, matchIndex - excerptPadding);
|
||||
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
|
||||
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first field of an entry to contain the search string, if any.
|
||||
* Fields are tried in the order we prefer to report a match.
|
||||
*/
|
||||
function findMatch(
|
||||
entry: SearchableEntry,
|
||||
customFields: CustomFields,
|
||||
filterKey: MaybeString,
|
||||
searchString: string,
|
||||
): FinderMatch | null {
|
||||
function check(key: string, label: string, value: string): FinderMatch | null {
|
||||
if (!value || (filterKey !== null && key !== filterKey)) {
|
||||
return null;
|
||||
}
|
||||
const matchIndex = value.toLowerCase().indexOf(searchString);
|
||||
if (matchIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
|
||||
}
|
||||
|
||||
// groups have no cue, the rest is common to every searchable entry
|
||||
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
|
||||
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
|
||||
if (match !== null) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// custom fields are named by the project, so these can only be reached generically
|
||||
for (const [key, value] of Object.entries(entry.custom)) {
|
||||
const definition = customFields[key];
|
||||
if (definition?.type !== 'text') {
|
||||
continue;
|
||||
}
|
||||
const custom = check(key, definition.label || key, value);
|
||||
if (custom) return custom;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the raw search value into an optional field filter and the text to look for.
|
||||
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
|
||||
*/
|
||||
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
|
||||
for (const filter of filters) {
|
||||
// the search value is already lowercased, custom field keys are not
|
||||
const prefix = filter.key.toLowerCase();
|
||||
if (searchValue === prefix) {
|
||||
return { filterKey: filter.key, searchString: '' };
|
||||
}
|
||||
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
|
||||
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
|
||||
}
|
||||
}
|
||||
return { filterKey: null, searchString: searchValue };
|
||||
}
|
||||
|
||||
/** Finds the single event at a 1-based position in the rundown */
|
||||
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
|
||||
const target = Number(indexString);
|
||||
if (isNaN(target) || target < 1) {
|
||||
return { ...noResults, error: 'Invalid index' };
|
||||
}
|
||||
|
||||
let eventIndex = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const entry = data[i];
|
||||
if (!isOntimeEvent(entry)) {
|
||||
continue;
|
||||
}
|
||||
eventIndex++;
|
||||
if (eventIndex === target) {
|
||||
return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
return noResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches entries on a single field when one is selected, otherwise on every text field.
|
||||
* Results keep rundown order, which keeps them predictable during a show.
|
||||
*/
|
||||
export function searchByText(
|
||||
data: OntimeEntry[],
|
||||
customFields: CustomFields,
|
||||
filterKey: MaybeString,
|
||||
searchString: string,
|
||||
): SearchOutcome {
|
||||
const results: FinderResult[] = [];
|
||||
let total = 0;
|
||||
// indexes exposed to the UI are 1-based
|
||||
let eventIndex = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const entry = data[i];
|
||||
if (isOntimeDelay(entry)) {
|
||||
continue;
|
||||
}
|
||||
const isEvent = isOntimeEvent(entry);
|
||||
if (isEvent) {
|
||||
eventIndex++;
|
||||
}
|
||||
|
||||
const match = findMatch(entry, customFields, filterKey, searchString);
|
||||
if (match === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total++;
|
||||
if (results.length < maxResults) {
|
||||
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
|
||||
}
|
||||
}
|
||||
|
||||
return { results, error: null, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param searchValue - the text the user is looking for
|
||||
* @param activeFilter - a field selected from the filter badges, if any
|
||||
*/
|
||||
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
|
||||
export default function useFinder() {
|
||||
const { data, rundownId } = useFlatRundown();
|
||||
const { data: customFields } = useCustomFields();
|
||||
const [results, setResults] = useState<FilterableEntry[]>([]);
|
||||
const [error, setError] = useState<MaybeString>(null);
|
||||
const lastSearchString = useRef('');
|
||||
|
||||
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
||||
|
||||
/** The filters offered to the user: the fixed fields plus whatever the project defines */
|
||||
const filters = useMemo<FinderFilter[]>(() => {
|
||||
const customFilters = Object.entries(customFields)
|
||||
.filter(([_key, field]) => field.type === 'text')
|
||||
.map(([key, field]) => ({ key, label: field.label || key }));
|
||||
return [...staticFilters, ...customFilters];
|
||||
}, [customFields]);
|
||||
/** Filters the rundown to a given evaluation */
|
||||
const find = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!data || data.length === 0) {
|
||||
setError('No data');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const { results, error, total, appliedFilter } = useMemo(() => {
|
||||
if (data.length === 0) {
|
||||
return { ...noResults, error: 'No data', appliedFilter: activeFilter };
|
||||
}
|
||||
if (event.target.value === '') {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalised = searchValue.trim().toLowerCase();
|
||||
if (normalised === '') {
|
||||
return { ...noResults, appliedFilter: activeFilter };
|
||||
}
|
||||
const searchValue = event.target.value.toLowerCase();
|
||||
lastSearchString.current = searchValue;
|
||||
|
||||
/**
|
||||
* A selected badge wins, but typing a keyword still works for anyone who knows them,
|
||||
* and lights up the matching badge rather than being silently ignored.
|
||||
*/
|
||||
const { filterKey, searchString } = activeFilter
|
||||
? { filterKey: activeFilter, searchString: normalised }
|
||||
: parseQuery(normalised, filters);
|
||||
if (searchValue.startsWith('index ')) {
|
||||
const searchString = searchValue.slice('index '.length).trim();
|
||||
const { results, error } = searchByIndex(searchString);
|
||||
setResults(results);
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (filterKey === indexFilter) {
|
||||
return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
|
||||
}
|
||||
if (searchString === '') {
|
||||
// a filter is selected, but there is nothing to match on yet
|
||||
return { ...noResults, appliedFilter: filterKey };
|
||||
}
|
||||
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
|
||||
}, [data, customFields, filters, searchValue, activeFilter]);
|
||||
if (searchValue.startsWith('cue ')) {
|
||||
const searchString = searchValue.slice('cue '.length).trim();
|
||||
const { results, error } = searchByCue(searchString);
|
||||
setResults(results);
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
|
||||
const { results, error } = searchByTitle(searchString);
|
||||
setResults(results);
|
||||
setError(error);
|
||||
|
||||
/** Returns a single item with a matching index */
|
||||
function searchByIndex(searchString: string) {
|
||||
const searchIndex = Number(searchString);
|
||||
if (isNaN(searchIndex) || searchIndex < 1) {
|
||||
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[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const event = data[i];
|
||||
if (isOntimeEvent(event)) {
|
||||
if (eventIndex === searchIndex) {
|
||||
results.push({
|
||||
type: SupportedEntry.Event,
|
||||
id: event.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
title: event.title,
|
||||
cue: event.cue,
|
||||
colour: event.colour,
|
||||
parent: event.parent,
|
||||
} satisfies FilterableEvent);
|
||||
break;
|
||||
}
|
||||
eventIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return { results, error: null };
|
||||
}
|
||||
|
||||
/** Returns maxResults of OntimeEvents that match the cue field */
|
||||
function searchByCue(searchString: string) {
|
||||
// indexes exposed to the UI are 1-based
|
||||
let eventIndex = 1;
|
||||
// limit amount of results we show
|
||||
let remaining = maxResults;
|
||||
const results: FilterableEvent[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
const event = data[i];
|
||||
if (isOntimeEvent(event)) {
|
||||
if (event.cue.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEntry.Event,
|
||||
id: event.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
title: event.title,
|
||||
cue: event.cue,
|
||||
colour: event.colour,
|
||||
parent: event.parent,
|
||||
} satisfies FilterableEvent);
|
||||
}
|
||||
eventIndex++;
|
||||
}
|
||||
}
|
||||
return { results, error: null };
|
||||
}
|
||||
|
||||
/** Returns maxResults of OntimeEvents that match the title field*/
|
||||
function searchByTitle(searchString: string) {
|
||||
// indexes exposed to the UI are 1-based
|
||||
let eventIndex = 1;
|
||||
// limit amount of results we show
|
||||
let remaining = maxResults;
|
||||
const results: FilterableEntry[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const entry = data[i];
|
||||
if (isOntimeEvent(entry)) {
|
||||
if (entry.title.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEntry.Event,
|
||||
id: entry.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
title: entry.title,
|
||||
cue: entry.cue,
|
||||
colour: entry.colour,
|
||||
parent: entry.parent,
|
||||
} satisfies FilterableEvent);
|
||||
}
|
||||
eventIndex++;
|
||||
} else if (isOntimeGroup(entry)) {
|
||||
if (entry.title.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEntry.Group,
|
||||
id: entry.id,
|
||||
index: i,
|
||||
title: entry.title,
|
||||
colour: entry.colour,
|
||||
} satisfies FilterableGroup);
|
||||
}
|
||||
} else if (isOntimeMilestone(entry)) {
|
||||
if (entry.title.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEntry.Milestone,
|
||||
id: entry.id,
|
||||
index: i,
|
||||
title: entry.title,
|
||||
cue: entry.cue,
|
||||
colour: entry.colour,
|
||||
parent: entry.parent,
|
||||
} satisfies FilterableMilestone);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { results, error: null };
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const select = useCallback(
|
||||
(result: FinderResult) => {
|
||||
selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
|
||||
(selectedEvent: FilterableEntry) => {
|
||||
selectAndRevealEntry({
|
||||
id: selectedEvent.id,
|
||||
index: selectedEvent.index,
|
||||
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
|
||||
});
|
||||
},
|
||||
[selectAndRevealEntry],
|
||||
);
|
||||
|
||||
return { select, results, error, total, filters, appliedFilter };
|
||||
/** clear results when source data changes */
|
||||
useEffect(() => {
|
||||
setResults([]);
|
||||
setError(null);
|
||||
// fake a submit event to re-run the search
|
||||
if (lastSearchString.current) {
|
||||
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
}, [data, find]);
|
||||
|
||||
return { find, select, results, error };
|
||||
}
|
||||
|
||||
@@ -195,6 +195,25 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =================== SOUND PROMPT ===================*/
|
||||
.sound-prompt {
|
||||
position: absolute;
|
||||
bottom: $view-block-padding;
|
||||
left: $view-inline-padding;
|
||||
padding: 0.5em 0.75em;
|
||||
border-radius: $element-border-radius;
|
||||
background-color: $viewer-card-bg-color;
|
||||
color: $viewer-secondary-color;
|
||||
font-size: $timer-label-size;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
transition: opacity $viewer-transition-time;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== LOGO ===================*/
|
||||
.logo {
|
||||
position: absolute;
|
||||
|
||||
@@ -8,6 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import { useTimerSocket } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
getTotalTime,
|
||||
} from './timer.utils';
|
||||
import { TimerData, useTimerData } from './useTimerData';
|
||||
import { useTimerSound } from './useTimerSound';
|
||||
|
||||
import './Timer.scss';
|
||||
|
||||
@@ -66,6 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
freezeOvertime,
|
||||
freezeMessage,
|
||||
hidePhase,
|
||||
endSound,
|
||||
font,
|
||||
keyColour,
|
||||
timerColour,
|
||||
@@ -75,6 +78,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const localisedMinutes = getLocalizedString('common.minutes');
|
||||
|
||||
const { showPrompt } = useTimerSound(time.phase, endSound);
|
||||
|
||||
// gather modifiers
|
||||
const viewTimerType = timerType ?? timerTypeNow;
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
@@ -156,6 +161,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
|
||||
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
|
||||
|
||||
{showPrompt && <EnableSoundPrompt />}
|
||||
|
||||
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
|
||||
|
||||
{!hideMessage && (
|
||||
@@ -227,3 +234,19 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudges the user to interact with the screen so that the browser allows audio playback
|
||||
* Any interaction arms the sound, so this is a hint rather than a control
|
||||
* It is tied to mouse movement since that does not itself grant playback permission,
|
||||
* which keeps the hint off screen unless somebody is at the machine to act on it
|
||||
*/
|
||||
function EnableSoundPrompt() {
|
||||
const isUserActive = useFadeOutOnInactivity(true);
|
||||
|
||||
return (
|
||||
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
|
||||
Tap the screen to enable sound
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
|
||||
import { shouldPlayEndSound } from '../timer.utils';
|
||||
|
||||
describe('shouldPlayEndSound()', () => {
|
||||
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
|
||||
'sounds when a running timer goes into overtime from %s',
|
||||
(previousPhase) => {
|
||||
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
|
||||
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent for a roll timer waiting to start', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('sounds once, not on every update while in overtime', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent on phases which are not the end of the timer', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
|
||||
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
|
||||
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,14 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'endSound',
|
||||
title: 'Play sound on timer end',
|
||||
description:
|
||||
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -193,6 +201,7 @@ type TimerOptions = {
|
||||
freezeOvertime: boolean;
|
||||
freezeMessage: string;
|
||||
hidePhase: boolean;
|
||||
endSound: boolean;
|
||||
font?: string;
|
||||
keyColour?: string;
|
||||
timerColour?: string;
|
||||
@@ -227,6 +236,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
||||
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
|
||||
freezeMessage: getValue('freezeMessage') ?? '',
|
||||
hidePhase: isStringBoolean(getValue('hidePhase')),
|
||||
endSound: isStringBoolean(getValue('endSound')),
|
||||
|
||||
font: getValue('font') ?? undefined,
|
||||
keyColour: makeColourString(getValue('keyColour')),
|
||||
|
||||
@@ -189,3 +189,18 @@ export function getCardData(
|
||||
nextSecondary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the end of timer sound should play for a given phase transition
|
||||
* We only sound the transition into overtime from a phase that was already counting,
|
||||
* which keeps a client that connects or reloads mid-overtime silent
|
||||
*/
|
||||
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
|
||||
if (phase !== TimerPhase.Overtime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import buzzer from '../../assets/sounds/buzzer.mp3';
|
||||
import { shouldPlayEndSound } from './timer.utils';
|
||||
|
||||
/**
|
||||
* Plays a sound when the timer reaches its end
|
||||
*
|
||||
* Browsers reject playback until the document has been interacted with, and that permission
|
||||
* is lost on every page load. Since a timer screen is typically left unattended, we prime the
|
||||
* audio element on the first interaction and let the view prompt for one if it never comes.
|
||||
* Safari grants the permission per element, so priming has to call play() on this element from
|
||||
* inside the event handler, it is not enough to know that an interaction happened.
|
||||
*/
|
||||
export function useTimerSound(phase: TimerPhase, enabled: boolean): { showPrompt: boolean } {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previousPhaseRef = useRef<TimerPhase | null>(null);
|
||||
const [isArmed, setIsArmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current = new Audio(buzzer);
|
||||
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
setIsArmed(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || isArmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const prime = () => {
|
||||
audioRef.current
|
||||
?.play()
|
||||
.then(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setIsArmed(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// playback is still blocked, a later interaction will try again
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal });
|
||||
document.addEventListener('keydown', prime, { capture: true, signal: controller.signal });
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [enabled, isArmed]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousPhase = previousPhaseRef.current;
|
||||
previousPhaseRef.current = phase;
|
||||
|
||||
if (!enabled || !shouldPlayEndSound(previousPhase, phase)) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current?.play().catch(() => {
|
||||
// the screen has not been interacted with, the view shows a prompt for it
|
||||
});
|
||||
}, [enabled, phase]);
|
||||
|
||||
return { showPrompt: enabled && !isArmed };
|
||||
}
|
||||
@@ -818,7 +818,7 @@ export async function renameRundown(id: string, title: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
await dataProvider.setRundown(id, { ...rundown, title });
|
||||
await dataProvider.setRundown(id, { ...rundown, title, revision: rundown.revision + 1 });
|
||||
|
||||
/**
|
||||
* If we are modifying the loaded rundown we re-init it
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Ontime architecture
|
||||
|
||||
Use for module placement or cross-boundary changes.
|
||||
|
||||
## Direction
|
||||
|
||||
Dependencies point toward pure domain logic:
|
||||
|
||||
```text
|
||||
HTTP request
|
||||
-> validation / router or controller
|
||||
-> service orchestration
|
||||
-> pure domain utilities
|
||||
|
||||
service orchestration
|
||||
-> DAO / stores / adapters / external clients
|
||||
```
|
||||
|
||||
Keep a layer only for clearer ownership, isolated side effects, or direct business-rule tests.
|
||||
|
||||
## Reuse and ownership
|
||||
|
||||
Before adding a module, helper, service, or interface, find the concept owner and inspect callers.
|
||||
|
||||
1. Reuse when the contract already matches.
|
||||
2. Extend for the same concept when the contract stays coherent.
|
||||
3. Keep local when reuse needs flags, broad optional inputs, unrelated modes, or leaky terms.
|
||||
4. Generalise only after stable common behaviour appears across real callers.
|
||||
|
||||
Do not duplicate canonical rules or distort an abstraction to force reuse. Small local duplication can beat coupling unrelated concepts.
|
||||
|
||||
## Server
|
||||
|
||||
### Routers and controllers
|
||||
|
||||
Routers declare paths and middleware. Controllers map validated HTTP input to typed service arguments, then results/errors to responses.
|
||||
|
||||
Keep reusable calculations, domain decisions, transformations, persistence workflows, and integration coordination out of handlers. Simple reads may stay direct when a service would only pass through.
|
||||
|
||||
### Services
|
||||
|
||||
Services orchestrate use cases and side-effect boundaries. Make ordering and effects visible. Move substantial branching, calculation, comparison, parsing, and transformation to pure utilities.
|
||||
|
||||
### Pure utilities
|
||||
|
||||
Pass required state, config, and time explicitly. No I/O, stores/globals, logging, websocket publication, browser inspection, or caller-owned mutation unless explicitly contracted.
|
||||
|
||||
Use focused Vitest coverage. Colocate feature logic. Move to `ontime-utils` only for genuine cross-package use.
|
||||
|
||||
### State and boundaries
|
||||
|
||||
- DAOs/data providers: persistence.
|
||||
- Stores: mutable runtime state.
|
||||
- Adapters/clients: external protocols and integrations.
|
||||
- Validators/parsers: protect boundaries before domain logic.
|
||||
- Commit state before dependent notifications or invalidations.
|
||||
|
||||
Old layering exceptions are context, not precedent. Improve touched boundaries only through focused, behaviour-preserving moves.
|
||||
|
||||
## Client
|
||||
|
||||
- `common/api`: HTTP transport.
|
||||
- `common/hooks-query`: TanStack Query reads, mutations, keys, cache, invalidation.
|
||||
- `features`: reusable product capabilities and domain behaviour.
|
||||
- `views`: route-level composition.
|
||||
- `common`: genuinely cross-feature code.
|
||||
|
||||
Keep substantial rules out of JSX, effects, and handlers. Use tested, colocated pure utilities. TanStack Query owns server state; established Zustand/context owns local state. No parallel caches.
|
||||
|
||||
Limit subscriptions with selectors. Keep effect dependencies stable. Clean up listeners, intervals, external resources.
|
||||
|
||||
## Shared packages
|
||||
|
||||
- `ontime-types`: shared contracts; type-focused.
|
||||
- `ontime-utils`: environment-independent, side-effect-free shared logic.
|
||||
- Never import application layers into shared packages.
|
||||
- Keep feature-specific helpers with their owner, even when used by another file.
|
||||
|
||||
## Review prompts
|
||||
|
||||
- Business rule understandable/testable without app startup?
|
||||
- Transport, orchestration, state, transformation separated?
|
||||
- Existing owner reused without forcing unrelated behaviour?
|
||||
- Abstraction removes concepts rather than relocating them?
|
||||
- Smallest focused remedy clear?
|
||||
@@ -0,0 +1,30 @@
|
||||
# Change assessment
|
||||
|
||||
Use before non-trivial planning/implementation and during non-trivial review. Let it shape scope and verification; avoid process theatre.
|
||||
|
||||
## Rate three dimensions
|
||||
|
||||
- **Value** — concrete user, product, operational, or maintenance benefit; include urgency.
|
||||
- **Risk** — regression likelihood/impact: data loss, security, cloud incompatibility, disruption, hard rollback.
|
||||
- **Complexity** — concepts, dependencies, layers, states, verification surfaces; not line count.
|
||||
|
||||
Use `Low`, `Medium`, or `High`. Give one evidence-based sentence each. No pseudo-precise scores.
|
||||
|
||||
```markdown
|
||||
## Assessment
|
||||
|
||||
- Value: High — <concrete benefit>
|
||||
- Risk: Medium — <failure modes and reversibility>
|
||||
- Complexity: Low — <conceptual and verification burden>
|
||||
- Recommendation: Proceed | Reshape | Defer — <why>
|
||||
```
|
||||
|
||||
One assessment per overall change.
|
||||
|
||||
- High value never excuses unmanaged risk/complexity.
|
||||
- High risk needs earlier proof, narrower increments, rollback, or stronger checks.
|
||||
- High complexity needs clearer boundaries and smaller steps, not automatic abstraction.
|
||||
- Low value plus high risk/complexity suggests reshape or defer.
|
||||
- Revise after material scope discovery.
|
||||
|
||||
Plans: assessment before steps. Reviews: findings first, then assessment. Assessment informs; never replaces user intent or evidence.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Simplicity and maintainability
|
||||
|
||||
Use for abstractions, comments, helpers, naming, or structural complexity.
|
||||
|
||||
## Simplicity
|
||||
|
||||
Choose the smallest explicit, testable design. No extension points, generic engines, wrappers, or config for hypothetical needs.
|
||||
|
||||
- Prefer direct flow over clever expressions or scattered conditions.
|
||||
- Extract only to name a rule, enable pure tests, or remove meaningful duplication.
|
||||
- Keep abstractions only when they reduce reader-held concepts.
|
||||
- Reuse the concept owner when semantics match.
|
||||
- Do not add flags, optional branches, generic names, or extension points to merge unlike cases.
|
||||
- Prefer focused local helpers over generic APIs exposing unrelated modes.
|
||||
- Keep scope narrow; note unrelated cleanup.
|
||||
- Delete clearly obsolete branches, harnesses, and shims. Ask when ownership or compatibility is unclear.
|
||||
|
||||
## Comments
|
||||
|
||||
Keep comments for:
|
||||
|
||||
- non-obvious intent or invariants;
|
||||
- necessary ordering, timing, mutation, or side effects;
|
||||
- browser, Electron, cloud, or protocol constraints;
|
||||
- workaround reasons and removal conditions;
|
||||
- public contracts names/types cannot express.
|
||||
|
||||
Remove comments that:
|
||||
|
||||
- narrate code or names;
|
||||
- number obvious steps;
|
||||
- explain mechanics better expressed by code;
|
||||
- preserve removed history;
|
||||
- claim untested behaviour;
|
||||
- use banners to hide oversized modules.
|
||||
|
||||
Update adjacent comments with code. Stale comments are defects.
|
||||
|
||||
## Naming and types
|
||||
|
||||
- Prefer Ontime terms over vague `data`, `result`, `item`.
|
||||
- Prefer explicit/discriminated types over `any`, broad casts, optional fields, non-null assertions, silent fallbacks.
|
||||
- Handle unions/enums exhaustively when future cases could be unsafe.
|
||||
- Keep public functions focused enough to avoid long contract explanations.
|
||||
|
||||
## Review standard
|
||||
|
||||
Do not demand personal style. Report complexity only when it risks maintenance, hides rules, blocks focused tests, duplicates ownership, or makes changes unsafe. Suggest reuse only after verifying the existing contract fits without added genericity.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Ontime domain invariants
|
||||
|
||||
Load only for touched domains. Add only stable, recurring invariants; not one-off bugs.
|
||||
|
||||
## Rundowns and entries
|
||||
|
||||
- Keep `entries`, `order`, `flatOrder` normalised.
|
||||
- Keep group membership, group entry lists, child `parent` references consistent.
|
||||
- Preserve entry identity and supported types across patch, clone, group, ungroup, reorder.
|
||||
- Distinguish loaded vs background rundown. Prefer explicit rundown ID over global current state.
|
||||
- No caller-owned rundown mutation unless explicitly contracted.
|
||||
|
||||
## Persistence, realtime, cache
|
||||
|
||||
- No partial commit on failure.
|
||||
- Preserve revision/transaction semantics for loaded and background rundowns.
|
||||
- Persist before websocket refetches, runtime updates, integration notifications, or cache assumptions.
|
||||
- Notify only invalidated consumers; never leave client cache stale.
|
||||
- Avoid duplicate listeners, notifications, invalidations, lifecycle effects.
|
||||
- Reconnect/refetch must converge on authoritative state.
|
||||
- Align query keys and websocket refetch keys with the changed resource.
|
||||
|
||||
## Timers
|
||||
|
||||
Use temporal values by meaning: `Instant` for epoch time, `TimeOfDay` for local time since midnight, `Duration` for elapsed time, `Day` for calendar offsets. Convert through `timeCore`; never interchange as raw numbers.
|
||||
|
||||
Active work: [runtimeState time-core migration](../migrations/runtime-state-time-core.md).
|
||||
|
||||
When relevant, cover interactions among:
|
||||
|
||||
- midnight/day offsets;
|
||||
- linked events/gaps;
|
||||
- delays/skipped entries;
|
||||
- count-to-end;
|
||||
- absolute/relative offsets;
|
||||
- warning, danger, finish, roll, end-action transitions;
|
||||
- loaded/next-event state.
|
||||
|
||||
Pass time/state explicitly to keep rules deterministic and unit-testable.
|
||||
|
||||
## Imports and migrations
|
||||
|
||||
- Treat project files, spreadsheets, custom fields, migrated data as untrusted.
|
||||
- Preserve fields the import/migration does not own.
|
||||
- Validate/parse into the current model before runtime logic.
|
||||
- Avoid source mutation; test round trips and non-mutation when preservation matters.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Routing and Ontime Cloud
|
||||
|
||||
Use for navigation, URLs, endpoints, websockets, auth, cookies, assets, redirects, presets, or local-storage scope.
|
||||
|
||||
## Deployment invariant
|
||||
|
||||
Support root and runtime-prefixed deployments:
|
||||
|
||||
```text
|
||||
local: http://localhost:4001/timer
|
||||
cloud: https://cloud.example/client-hash/timer
|
||||
```
|
||||
|
||||
Prefix is deployment data. Never assume `/`.
|
||||
|
||||
## Client
|
||||
|
||||
- `apps/client/src/externals.ts`: derives `baseURI`, `serverURL`, `websocketUrl` from document base/current origin.
|
||||
- `BrowserRouter`: uses `baseURI` basename.
|
||||
- APIs/assets: use `common/api/constants.ts` or base-aware helpers.
|
||||
- App navigation: use React Router. Never strip/guess/re-add prefix from `window.location.pathname`.
|
||||
- Persisted browser state: use existing base-aware scoping where prefixes need isolation.
|
||||
|
||||
## Server
|
||||
|
||||
- `updateRouterPrefix()` in `apps/server/src/externals.ts`: normalises `ROUTER_PREFIX`.
|
||||
- `apps/server/src/app.ts`: mounts routes below that prefix.
|
||||
- Domain routers: paths relative to mount; never derive prefix.
|
||||
- Websockets, auth redirects, cookie paths, share URLs: preserve prefix.
|
||||
|
||||
## URL rules
|
||||
|
||||
Use `URL`, React Router, or existing helpers instead of string manipulation. Preserve:
|
||||
|
||||
- leading/trailing slashes and runtime prefix;
|
||||
- query params, auth tokens, navigation locks;
|
||||
- preset aliases and canonical view paths;
|
||||
- `https`/`wss` behind proxies;
|
||||
- static/user asset paths.
|
||||
|
||||
Never infer Ontime Cloud from hostname alone. Generated base markup marks cloud; runtime prefixes also serve non-cloud reverse proxies.
|
||||
|
||||
## Cloud capabilities
|
||||
|
||||
Gate unavailable local-network integrations in cloud, including OSC output. Keep server behaviour and UI availability aligned.
|
||||
|
||||
## Verification
|
||||
|
||||
Test both root and a prefix such as `/client-hash`. Include relevant queries, redirects, cookies, websocket paths, presets, assets. Root-only routing coverage is incomplete.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Security boundaries
|
||||
|
||||
Use for auth, external input, files, integrations, assets, URLs, or secrets.
|
||||
|
||||
## Untrusted inputs
|
||||
|
||||
Validate/parse before trust:
|
||||
|
||||
- HTTP bodies, params, headers, cookies, websocket messages;
|
||||
- project files, migrations, spreadsheets, imports;
|
||||
- custom HTML/CSS/views, translations, served assets;
|
||||
- automation payloads, third-party responses;
|
||||
- externally supplied paths/filenames.
|
||||
|
||||
Validate shape and domain constraints at the owning boundary. Keep existing parser/validation layers; avoid downstream defensive casts.
|
||||
|
||||
## Authentication and authorisation
|
||||
|
||||
- Keep protected routers behind auth middleware in `apps/server/src/app.ts`.
|
||||
- Preserve auth across prefixed routes, redirects, websockets, generated links.
|
||||
- Scope session cookies to runtime prefix; isolate hosted clients.
|
||||
- Authentication never grants arbitrary file, path, project, or rundown access.
|
||||
|
||||
## Files, URLs, integrations
|
||||
|
||||
- Use established path/file helpers. Reject traversal and unexpected types.
|
||||
- Build URLs with `URL` or existing helpers. Check open redirects, SSRF, protocol changes, token leaks.
|
||||
- Encode/constrain untrusted HTML, CSS, filenames, headers, log values.
|
||||
- Preserve cloud limits on local-network capabilities.
|
||||
|
||||
## Secrets and diagnostics
|
||||
|
||||
- Never commit/log passwords, hashes, tokens, credentials, cookies, private project content.
|
||||
- Errors: useful, but no internal paths, stacks, credentials, sensitive payloads.
|
||||
- Keep tokens in existing session/authenticated-share flows. Avoid new URL-token patterns.
|
||||
|
||||
## Review prompts
|
||||
|
||||
- Where does input become trusted?
|
||||
- Validation once at owner, then useful type?
|
||||
- Can one prefixed client cross another client's session/assets?
|
||||
- Can logs, responses, redirects, URLs leak secrets?
|
||||
@@ -0,0 +1,52 @@
|
||||
# Testing strategy
|
||||
|
||||
Use for changed behaviour or tests.
|
||||
|
||||
## Layers
|
||||
|
||||
### Pure unit
|
||||
|
||||
Put detailed business-rule coverage on pure functions. Test public inputs/outputs with Vitest: relevant boundaries, invalid input, ordering, rollover, errors.
|
||||
|
||||
Every bug fix needs a regression test that fails before the fix. Prefer behaviour over mocks/implementation assertions.
|
||||
|
||||
### Service and state
|
||||
|
||||
Use service, DAO, store, or hook tests for orchestration: transitions, transactions, persistence, cache, notifications, external effects.
|
||||
|
||||
Do not repeat all pure cases here. Prove delegation and sequencing.
|
||||
|
||||
### End-to-end
|
||||
|
||||
Reserve Playwright for key journeys and high-risk cross-layer integrations: edit/run rundown, playback, imports, rundown switching, cloud-prefixed navigation.
|
||||
|
||||
No E2E for edges already proven in lower layers. Add E2E only when lower layers cannot prove the user-facing integration.
|
||||
|
||||
## Compact before handoff
|
||||
|
||||
Development harnesses may be broad, repetitive, diagnostic, temporary. Before handoff:
|
||||
|
||||
1. Identify distinct required behaviours/regressions.
|
||||
2. Keep the smallest readable set that catches them.
|
||||
3. Parameterise repetition only when the table reads better.
|
||||
4. Remove diagnostic assertions, redundant permutations, temporary fixtures, private-detail coupling.
|
||||
5. Keep rare cases that encode real domain rules.
|
||||
|
||||
Optimise for future readers, not minimum line count.
|
||||
|
||||
## Quality
|
||||
|
||||
- Name tests by observable behaviour.
|
||||
- Keep setup local/explicit unless a fixture improves comprehension.
|
||||
- Avoid arbitrary waits, wall-clock dependence, cross-test state, weak assertions.
|
||||
- Prefer realistic typed fixtures over large snapshots or masking casts.
|
||||
- Test non-mutation when promised.
|
||||
- No tests for trivial type/format changes or framework behaviour Ontime does not own.
|
||||
|
||||
## Review prompts
|
||||
|
||||
- Business logic directly unit-testable?
|
||||
- Test catches the reported bug/regression?
|
||||
- Cases distinct, not repeated path?
|
||||
- Temporary harness leaked?
|
||||
- E2E justified by cross-layer risk?
|
||||
@@ -0,0 +1,69 @@
|
||||
# Workflow and repository conventions
|
||||
|
||||
Use for commands, imports, formatting, CI, dependencies, PRs.
|
||||
|
||||
## Workspaces
|
||||
|
||||
Confirm names from `package.json`.
|
||||
|
||||
| Package | Workspace name |
|
||||
| ---------------- | --------------------- |
|
||||
| Client | `ontime-ui` |
|
||||
| Server | `ontime-server` |
|
||||
| Electron | `ontime-electron` |
|
||||
| Resolver | `@getontime/resolver` |
|
||||
| CLI | `@getontime/cli` |
|
||||
| Shared types | `ontime-types` |
|
||||
| Shared utilities | `ontime-utils` |
|
||||
|
||||
Run from repo root: `pnpm --filter <workspace-name> <script>`. Add/install workspaces only when required.
|
||||
|
||||
## Verification
|
||||
|
||||
Start narrow; expand with risk:
|
||||
|
||||
```text
|
||||
focused test
|
||||
-> affected package tests
|
||||
-> package lint/typecheck
|
||||
-> required repository checks
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm --filter ontime-ui test:pipeline <path-to-test>
|
||||
pnpm --filter ontime-server test:pipeline <path-to-test>
|
||||
pnpm --filter ontime-utils test:pipeline <path-to-test>
|
||||
pnpm --filter <workspace-name> lint
|
||||
pnpm --filter <workspace-name> typecheck
|
||||
|
||||
pnpm format:check
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm e2e
|
||||
```
|
||||
|
||||
PR CI: `.github/workflows/test.yml`; runs format, lint, types, unit, Playwright. Run E2E locally only for key flows or E2E infrastructure.
|
||||
|
||||
Before code commit: `pnpm lint`, `pnpm test`; add `pnpm typecheck` for TypeScript and `pnpm format:check` for formatted files. Claim only observed results.
|
||||
|
||||
## TypeScript and imports
|
||||
|
||||
- Strict TypeScript. Prefer `ontime-types` domain types over local copies.
|
||||
- Keep client/server payloads aligned through `ontime-types`.
|
||||
- Import `ontime-types`/`ontime-utils` from public entry points; Oxlint forbids `src` subpaths.
|
||||
- Server relative ESM imports need `.js`.
|
||||
- Reuse public exports/canonical helpers before adding utilities.
|
||||
|
||||
## Formatting and dependencies
|
||||
|
||||
- Use Oxlint/Oxfmt, not ESLint/Prettier.
|
||||
- Oxfmt: two spaces, semicolons, single quotes, trailing commas, 120 columns.
|
||||
- Add dependencies only when platform/current stack cannot solve the need. Review `package.json` and `pnpm-lock.yaml` together.
|
||||
- Never hand-edit lockfile. No build output unless already tracked.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Title: `[<workspace-name>] <Title>`.
|
||||
- Separate behaviour from unrelated refactors/format churn.
|
||||
- State what changed, why, verification.
|
||||
@@ -0,0 +1,67 @@
|
||||
# runtimeState time-core migration
|
||||
|
||||
**Status:** In progress
|
||||
|
||||
## Goal
|
||||
|
||||
Make temporal meaning explicit across `runtimeState` and timer calculations. Prevent mixing:
|
||||
|
||||
- `Instant`: epoch timestamp;
|
||||
- `TimeOfDay`: local milliseconds since midnight, range `[0, dayInMs)`;
|
||||
- `Duration`: elapsed or remaining milliseconds;
|
||||
- `Day`: calendar-day offset.
|
||||
|
||||
Use `apps/server/src/lib/time-core/timeCore.ts` for conversions and temporal arithmetic. Branded types remain numbers at serialization boundaries.
|
||||
|
||||
## Current state
|
||||
|
||||
Completed foundation:
|
||||
|
||||
- temporal brands in `ontime-types`;
|
||||
- `timeCore` helpers for now, conversion, duration arithmetic, midnight crossing, calendar-day distance;
|
||||
- partial `runtimeState` adoption of `Instant`, `TimeOfDay`, `Day`, and `timeCore`.
|
||||
|
||||
Remaining ambiguity:
|
||||
|
||||
- `TimerState`, `RundownState`, and `Offset` expose temporal fields as `number`/`MaybeNumber`;
|
||||
- `timerUtils` accepts/returns raw numbers with different meanings;
|
||||
- `runtimeState` retains raw duration fields, manual arithmetic, and `as TimeOfDay`/`as Duration` casts.
|
||||
|
||||
## Migration rules
|
||||
|
||||
- Classify each temporal field before changing it. No generic `Time` type.
|
||||
- Convert only through `timeCore` or an explicit transport adapter.
|
||||
- Keep public/websocket JSON numeric where required; brand at the boundary.
|
||||
- Keep type migration separate from behaviour changes.
|
||||
- Preserve current midnight, rollover, pause, add-time, roll, and offset behaviour per slice.
|
||||
- Add helpers only when they encode a named temporal rule and remove caller casts/arithmetic.
|
||||
|
||||
## Slices
|
||||
|
||||
- [x] Add temporal brands and initial `timeCore` helpers/tests.
|
||||
- [ ] Inventory temporal fields in `TimerState`, `RundownState`, `Offset`, and private runtime state; assign intended types.
|
||||
- [ ] Migrate pure `timerUtils` functions by temporal concept; add focused midnight/rollover tests.
|
||||
- [ ] Migrate private `RuntimeState` fields and calculations; remove local casts/manual conversions.
|
||||
- [ ] Migrate shared runtime contracts and add numeric transport adapters where compatibility requires them.
|
||||
- [ ] Update remaining callers, fixtures, and mocks.
|
||||
- [ ] Remove superseded helpers, casts, and ambiguous temporal numbers.
|
||||
|
||||
Each slice must leave old/new boundaries explicit, compile cleanly, and preserve behaviour.
|
||||
|
||||
## Required coverage
|
||||
|
||||
- before, at, and after midnight;
|
||||
- overnight and multi-day rundowns;
|
||||
- local-day calculation across timezone/DST offset changes;
|
||||
- pause/resume, added time, elapsed/remaining duration;
|
||||
- roll secondary targets and expected finish;
|
||||
- absolute/relative offsets and day offsets.
|
||||
|
||||
## Complete when
|
||||
|
||||
- Runtime/timer boundaries use semantic temporal types or documented numeric transport fields.
|
||||
- Temporal conversions/arithmetic use `timeCore` or named pure helpers.
|
||||
- No unexplained temporal casts or ambiguous numeric fields remain in migrated scope.
|
||||
- Focused tests cover the required boundaries.
|
||||
|
||||
After completion, remove this migration tracker. Keep durable rules in `docs/agent-guides/domain-invariants.md`.
|
||||
@@ -222,57 +222,15 @@ test('Delete event', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'Create Group' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Finder searches the rundown and reveals a result', async ({ page }) => {
|
||||
test('Find in rundown', async ({ page }) => {
|
||||
await page.goto('/rundown');
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByTestId('panel-rundown')).toBeVisible();
|
||||
|
||||
// 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);
|
||||
|
||||
// two events, where the one we are looking for is identified only by its note
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await page.getByTestId('entry-1').click();
|
||||
await page.getByTestId('entry__title').press('Escape');
|
||||
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+E');
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
|
||||
|
||||
await page.getByTestId('entry-1').getByTestId('entry__title').fill('opening');
|
||||
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
|
||||
await page.getByTestId('entry-2').getByTestId('entry__title').fill('closing');
|
||||
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
|
||||
|
||||
await page.getByTestId('entry-2').click();
|
||||
await page.getByLabel('Note', { exact: true }).fill('remember the zebrafish');
|
||||
await page.getByLabel('Note', { exact: true }).press('Tab');
|
||||
|
||||
// the shortcut has to work from a focused field, which is where it is usually reached for
|
||||
await page.getByTestId('entry-2').getByTestId('entry__title').click();
|
||||
await page.keyboard.press('ControlOrMeta+f');
|
||||
await expect(page.getByPlaceholder('Search...')).toBeFocused();
|
||||
await expect(page.getByPlaceholder('Search...')).toBeVisible();
|
||||
|
||||
// a bare query reaches the note, and the result names the field it matched
|
||||
await page.getByPlaceholder('Search...').fill('zebrafish');
|
||||
await expect(page.getByTestId('finder-result')).toHaveCount(1);
|
||||
await expect(page.getByTestId('finder-result-match')).toContainText('Note');
|
||||
|
||||
// a badge scopes the search to one field, without putting syntax in the input
|
||||
const titleFilter = page.getByTestId('finder-filters').getByRole('button', { name: 'Title', exact: true });
|
||||
await titleFilter.click();
|
||||
await expect(page.getByPlaceholder('Search...')).toHaveValue('zebrafish');
|
||||
await expect(page.getByTestId('finder-result')).toHaveCount(0);
|
||||
|
||||
// pressing it again searches every field once more
|
||||
await titleFilter.click();
|
||||
await expect(page.getByTestId('finder-result')).toHaveCount(1);
|
||||
|
||||
// choosing a result closes the finder and selects the entry in the rundown
|
||||
await page.getByPlaceholder('Search...').press('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByPlaceholder('Search...')).toBeHidden();
|
||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true');
|
||||
});
|
||||
|
||||
test('Open settings', async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user