refactor(report): finish via playback, one file per run

Uses the Finish the app already has, and stops rewriting the whole
history every time a show ends.

- The Go button already reads "Finish" on the last event and calls stop
  (playbackControl.utils). Closing the run hangs off that instead of a
  second Finish button of its own, so POST /report/runs/finish and the
  button in the overview are both gone. The run indicator stays as the
  way the feature is found, now a passive status that opens the reports
  panel.
- Reports move from one file per project to a directory per project with
  one file per run. Finishing a show wrote the entire history back to
  disk, which grew with every show ever recorded; it now writes only the
  run that just ended. Deleting a run unlinks one file and deleting a
  project removes one directory.
- A report that cannot be read is skipped instead of taking the rest of
  the history with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
Claude
2026-08-09 14:59:57 +00:00
parent b21385ec4a
commit a851414d13
11 changed files with 262 additions and 223 deletions
-8
View File
@@ -60,14 +60,6 @@ export async function fetchOpenRun(options?: RequestOptions): Promise<OpenRun |
}
}
/**
* HTTP request to finish the run in progress, which is what writes it to history
*/
export async function finishRun(): Promise<void> {
await axios.post(`${reportUrl}/runs/finish`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function renameRun(id: string, label: string): Promise<ShowRun> {
const res = await axios.patch(`${reportUrl}/runs/${id}`, { label });
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
@@ -1,16 +1,20 @@
.indicator {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.label {
display: flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: calc(1rem - 3px);
color: $label-gray;
&:hover {
color: $ui-white;
}
}
.dot {
@@ -1,58 +1,39 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { finishRun } from '../../../common/api/report';
import Button from '../../../common/components/buttons/Button';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useOpenRun } from '../../../common/hooks-query/useRuns';
import style from './RunIndicator.module.scss';
/**
* Shows that a show report is being recorded, and lets the operator close it.
* Shows that a report is being recorded for the show in progress.
*
* This is the feature's home in the editor: it appears only while a run is
* open, so it stays out of the way until it is relevant, and finishing here
* is what writes the run to history.
* This is how the feature is discovered: it appears with the first event and
* points at the reports panel. Ending the run is the existing Finish action
* on the last event, so there is no separate control here.
*/
export default function RunIndicator() {
const openRun = useOpenRun();
const [isFinishing, setIsFinishing] = useState(false);
const navigate = useNavigate();
if (!openRun) {
return null;
}
const handleFinish = async () => {
setIsFinishing(true);
try {
await finishRun();
// take the user to the report they just made, which is also how most
// people will discover that the history exists
void navigate('/editor?settings=sharing__report');
} catch (_error) {
/** the run stays open, the user can try again */
} finally {
setIsFinishing(false);
}
};
// startedAt is a wall clock instant, not a time of day, so formatTime does not apply
const since = new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' });
return (
<div className={style.indicator}>
<Tooltip text='A report is being recorded for this show' render={<span />}>
<span className={style.label}>
<span className={style.dot} />
{/* startedAt is a wall clock instant, not a time of day, so it is not formatTime's job */}
Recording since{' '}
<span className={style.since}>
{new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' })}
</span>
</span>
</Tooltip>
<Button size='small' variant='subtle' onClick={handleFinish} disabled={isFinishing}>
Finish run
</Button>
</div>
<Tooltip text='A report is being recorded. It is saved when you finish the show.' render={<span />}>
<button
type='button'
className={style.indicator}
onClick={() => navigate('/editor?settings=sharing__report')}
aria-label='Show reports'
>
<span className={style.dot} />
Recording since <span className={style.since}>{since}</span>
</button>
</Tooltip>
);
}
@@ -13,7 +13,7 @@ let writes = 0;
vi.mock('../../../services/report-service/report.store.js', () => ({
// isolation between tests comes from the top-level beforeEach resetting `runs`,
// this mirrors the real store returning whatever is already on "disk"
loadReports: vi.fn(async () => ({ runs })),
loadReports: vi.fn(async () => runs),
getRuns: vi.fn(() => runs),
getRun: vi.fn((id: string) => runs.find((run) => run.id === id)),
upsertRun: vi.fn(async (run: ShowRun) => {
@@ -37,18 +37,6 @@ router.get('/runs/open', (_req: Request, res: Response) => {
res.status(200).json(run);
});
/**
* Closes the run in progress and writes it to history immediately.
* Registered ahead of /runs/:id so "finish" is not read as an id.
*/
router.post('/runs/finish', async (_req: Request, res: Response) => {
const run = await report.closeRun();
if (!run) {
res.status(404).send();
return;
}
res.status(200).json(run);
});
router.get('/runs/:id', paramsWithId, (req: Request, res: Response) => {
const { id } = req.params;
@@ -1,7 +1,9 @@
import { join } from 'path';
import type { ShowRun } from 'ontime-types';
import { vi } from 'vitest';
// in-memory stand-in for the JSON file on disk, keyed by path
// in-memory stand-in for the filesystem, keyed by absolute path
const files = new Map<string, unknown>();
vi.mock('lowdb/node', () => {
@@ -24,18 +26,35 @@ vi.mock('../../../utils/fileManagement.js', async () => {
const actual = await vi.importActual<typeof import('../../../utils/fileManagement.js')>(
'../../../utils/fileManagement.js',
);
const childrenOf = (dir: string) => [...files.keys()].filter((path) => path.startsWith(`${dir}/`));
return {
...actual,
ensureDirectory: vi.fn(),
readDirectoryEntries: vi.fn(async (dir: string) => {
const children = childrenOf(dir);
if (children.length === 0) throw new Error('ENOENT');
return children.map((path) => ({
name: path.slice(dir.length + 1),
isFile: () => true,
}));
}),
deleteFile: vi.fn(async (path: string) => {
files.delete(path);
}),
dockerSafeRename: vi.fn(async (oldPath: string, newPath: string) => {
if (files.has(oldPath)) {
files.set(newPath, files.get(oldPath));
files.delete(oldPath);
deleteDirectory: vi.fn(async (dir: string) => {
for (const path of childrenOf(dir)) files.delete(path);
}),
dockerSafeRename: vi.fn(async (oldDir: string, newDir: string) => {
for (const path of childrenOf(oldDir)) {
files.set(join(newDir, path.slice(oldDir.length + 1)), files.get(path));
files.delete(path);
}
}),
statIfExists: vi.fn(async (path: string) => (files.has(path) ? {} : null)),
statIfExists: vi.fn(async (path: string) =>
files.has(path) || childrenOf(path).length > 0 ? {} : null,
),
};
});
@@ -58,7 +77,7 @@ function makeRun(patch: Partial<ShowRun> = {}): ShowRun {
id: 'run-1',
rundownId: 'rundown-1',
rundownTitle: 'My rundown',
label: '2026-08-08',
label: '8 Aug 2026, 09:30',
startedAt: 1000,
endedAt: 2000,
report: {},
@@ -77,34 +96,55 @@ function makeRun(patch: Partial<ShowRun> = {}): ShowRun {
};
}
/** where a run's file lands for a given project */
const runPath = (project: string, id: string) => join(getPathToReports(project), `${id}.json`);
beforeEach(() => {
files.clear();
resetStore();
});
describe('loadReports()', () => {
it('starts empty for a project with no sidecar', async () => {
const result = await loadReports('project-a');
expect(result.runs).toEqual([]);
it('starts empty for a project with no reports', async () => {
expect(await loadReports('project-a')).toEqual([]);
expect(getRuns()).toEqual([]);
});
it('loads runs already on disk', async () => {
files.set(getPathToReports('project-a'), { runs: [makeRun()] });
const result = await loadReports('project-a');
expect(result.runs).toHaveLength(1);
expect(getRuns()[0].id).toBe('run-1');
it('resolves a directory per project, ignoring the file extension', async () => {
expect(getPathToReports('my show.json')).toBe(getPathToReports('my show'));
});
it('discards a corrupt sidecar rather than throwing', async () => {
files.set(getPathToReports('project-a'), { runs: 'not-an-array' });
const result = await loadReports('project-a');
expect(result.runs).toEqual([]);
it('loads every run in the project directory', async () => {
files.set(runPath('project-a', 'a'), makeRun({ id: 'a' }));
files.set(runPath('project-a', 'b'), makeRun({ id: 'b' }));
const runs = await loadReports('project-a');
expect(runs).toHaveLength(2);
});
it('presents runs newest first regardless of read order', async () => {
files.set(runPath('project-a', 'old'), makeRun({ id: 'old', startedAt: 1000 }));
files.set(runPath('project-a', 'new'), makeRun({ id: 'new', startedAt: 9000 }));
files.set(runPath('project-a', 'mid'), makeRun({ id: 'mid', startedAt: 5000 }));
await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['new', 'mid', 'old']);
});
it('skips an unreadable report without losing the rest', async () => {
files.set(runPath('project-a', 'good'), makeRun({ id: 'good' }));
files.set(runPath('project-a', 'broken'), { nonsense: true });
await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['good']);
});
it('scopes runs to the loaded project', async () => {
files.set(getPathToReports('project-a'), { runs: [makeRun({ id: 'a' })] });
files.set(getPathToReports('project-b'), { runs: [makeRun({ id: 'b' })] });
files.set(runPath('project-a', 'a'), makeRun({ id: 'a' }));
files.set(runPath('project-b', 'b'), makeRun({ id: 'b' }));
await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['a']);
@@ -114,14 +154,18 @@ describe('loadReports()', () => {
});
});
describe('upsertRun() / getRun() / getRuns()', () => {
describe('upsertRun()', () => {
beforeEach(async () => {
await loadReports('project-a');
});
it('inserts a new run at the front of the list', async () => {
it('writes only the run given, not the whole history', async () => {
await upsertRun(makeRun({ id: 'first' }));
await upsertRun(makeRun({ id: 'second' }));
// one file each, so the cost of finishing a show does not grow with history
expect(files.has(runPath('project-a', 'first'))).toBe(true);
expect(files.has(runPath('project-a', 'second'))).toBe(true);
expect(getRuns().map((run) => run.id)).toEqual(['second', 'first']);
});
@@ -133,15 +177,12 @@ describe('upsertRun() / getRun() / getRuns()', () => {
expect(getRun('run-1')?.label).toBe('renamed');
});
it('persists to disk', async () => {
it('survives a reload', async () => {
await upsertRun(makeRun());
const reloaded = await loadReports('project-a');
expect(reloaded.runs).toHaveLength(1);
expect(await loadReports('project-a')).toHaveLength(1);
});
});
describe('deleteRun()', () => {
beforeEach(async () => {
await loadReports('project-a');
@@ -149,10 +190,12 @@ describe('deleteRun()', () => {
await upsertRun(makeRun({ id: 'discard' }));
});
it('removes only the targeted run', async () => {
const didDelete = await deleteRun('discard');
expect(didDelete).toBe(true);
it('removes only the targeted run and its file', async () => {
expect(await deleteRun('discard')).toBe(true);
expect(getRuns().map((run) => run.id)).toEqual(['keep']);
expect(files.has(runPath('project-a', 'discard'))).toBe(false);
expect(files.has(runPath('project-a', 'keep'))).toBe(true);
});
it('reports false for a run that does not exist', async () => {
@@ -168,48 +211,65 @@ describe('deleteRunsForRundown()', () => {
await upsertRun(makeRun({ id: 'b', rundownId: 'rundown-y' }));
await upsertRun(makeRun({ id: 'c', rundownId: 'rundown-x' }));
const removed = await deleteRunsForRundown('rundown-x');
expect(await deleteRunsForRundown('rundown-x')).toBe(2);
expect(removed).toBe(2);
expect(getRuns().map((run) => run.id)).toEqual(['b']);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
expect(files.has(runPath('project-a', 'c'))).toBe(false);
});
});
describe('deleteAllRuns()', () => {
it('empties the run history', async () => {
it('empties the project directory', async () => {
await loadReports('project-a');
await upsertRun(makeRun());
await upsertRun(makeRun({ id: 'a' }));
await upsertRun(makeRun({ id: 'b' }));
await deleteAllRuns();
expect(getRuns()).toEqual([]);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
});
});
describe('project lifecycle', () => {
it('deletes the sidecar for a project', async () => {
it('removes the whole directory with the project', async () => {
await loadReports('project-a');
await upsertRun(makeRun());
expect(files.has(getPathToReports('project-a'))).toBe(true);
await upsertRun(makeRun({ id: 'a' }));
await upsertRun(makeRun({ id: 'b' }));
await deleteReportsForProject('project-a');
expect(files.has(getPathToReports('project-a'))).toBe(false);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
expect(files.has(runPath('project-a', 'b'))).toBe(false);
});
it('does nothing when the project never had a sidecar', async () => {
it('does nothing when the project never had reports', async () => {
await expect(deleteReportsForProject('never-loaded')).resolves.toBeUndefined();
});
it('moves the sidecar to follow a project rename', async () => {
it('moves the directory to follow a project rename', async () => {
await loadReports('project-a');
await upsertRun(makeRun());
await upsertRun(makeRun({ id: 'a' }));
await renameReportsForProject('project-a', 'project-b');
expect(files.has(getPathToReports('project-a'))).toBe(false);
const moved = files.get(getPathToReports('project-b')) as { runs: ShowRun[] };
expect(moved.runs).toHaveLength(1);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
expect(files.has(runPath('project-b', 'a'))).toBe(true);
});
it('does nothing when renaming a project that never had a sidecar', async () => {
it('keeps writing to the new location after a rename', async () => {
await loadReports('project-a');
await upsertRun(makeRun({ id: 'a' }));
await renameReportsForProject('project-a', 'project-b');
await upsertRun(makeRun({ id: 'later' }));
expect(files.has(runPath('project-b', 'later'))).toBe(true);
expect(files.has(runPath('project-a', 'later'))).toBe(false);
});
it('does nothing when renaming a project that never had reports', async () => {
await expect(renameReportsForProject('never-loaded', 'still-never-loaded')).resolves.toBeUndefined();
});
});
@@ -1,32 +1,24 @@
import type { ProjectReports } from 'ontime-types';
import type { ShowRun } from 'ontime-types';
import { is } from '../../utils/is.js';
/**
* Shallow check on the contents of a report sidecar.
* Shallow check on the contents of a report file.
*
* We are the only writer of this file, so this guards against it being
* missing, empty or hand edited rather than against arbitrary payloads.
* Anything that fails is discarded and the project starts with no history,
* which is the same outcome as a first run.
* We are the only writer of these files, so this guards against one being
* empty, truncated or hand edited rather than against arbitrary payloads.
* A file that fails is skipped, leaving the rest of the history readable.
*/
export function isProjectReports(value: unknown): value is ProjectReports {
if (!is.object(value) || !is.objectWithKeys(value, ['runs'])) {
export function isShowRun(value: unknown): value is ShowRun {
if (!is.object(value) || !is.objectWithKeys(value, ['id', 'startedAt', 'endedAt', 'report', 'summary'])) {
return false;
}
if (!is.array(value.runs)) {
return false;
}
return value.runs.every(isRunShaped);
}
/** Enough of a run to be listed and opened without throwing */
function isRunShaped(value: unknown): boolean {
if (!is.object(value) || !is.objectWithKeys(value, ['id', 'startedAt', 'report', 'summary'])) {
return false;
}
return is.string(value.id) && is.number(value.startedAt) && is.object(value.report) && is.object(value.summary);
return (
is.string(value.id) &&
is.number(value.startedAt) &&
is.number(value.endedAt) &&
is.object(value.report) &&
is.object(value.summary)
);
}
@@ -1,86 +1,115 @@
import { join } from 'path';
import { JSONFile } from 'lowdb/node';
import type { ProjectReports, ShowRun } from 'ontime-types';
import type { ShowRun } from 'ontime-types';
import { publicDir } from '../../setup/index.js';
import { deleteFile, dockerSafeRename, ensureJsonExtension, statIfExists } from '../../utils/fileManagement.js';
import { isProjectReports } from './report.parser.js';
import {
deleteDirectory,
deleteFile,
dockerSafeRename,
ensureDirectory,
readDirectoryEntries,
removeFileExtension,
statIfExists,
} from '../../utils/fileManagement.js';
import { isShowRun } from './report.parser.js';
/**
* Reports are kept in a sidecar file per project rather than in the project
* itself. This keeps the project file free of mid-show writes and lets the
* run history grow without bloating what the user exports.
* Reports live in a directory per project, one file per run.
*
* Keeping them out of the project file leaves it free of show time writes and
* lets history grow without bloating what the user exports. Keeping each run
* in its own file means finishing a show writes only that show, rather than
* rewriting everything the project has ever recorded.
*
* Persistence is best effort by design: a failing disk degrades reporting
* but must never interrupt a running show.
*/
/**
* Returns a fresh empty store.
* Must not be a shared constant: `cache.runs` is mutated in place elsewhere
* in this module, and a shared array would leak state between projects.
*/
function emptyStore(): ProjectReports {
return { runs: [] };
}
let fileRef: JSONFile<ProjectReports> | null = null;
let cache: ProjectReports = emptyStore();
/** runs for the loaded project, newest first */
let cache: ShowRun[] = [];
let projectDir: string | null = null;
let failedWriteAttempts = 0;
/**
* Resolves the sidecar path for a given project file name
*/
/** Directory holding a project's reports */
export function getPathToReports(projectFilename: string): string {
return join(publicDir.reportsDir, ensureJsonExtension(projectFilename));
// the project name without its extension, so "show.json" does not read as a file
return join(publicDir.reportsDir, removeFileExtension(projectFilename));
}
function getPathToRun(id: string): string | null {
return projectDir === null ? null : join(projectDir, `${id}.json`);
}
/**
* Points the store at a project's sidecar and loads whatever is on disk.
* Points the store at a project's report directory and loads what is there.
* Called on every project load, which is the single choke point for
* project changes.
*/
export async function loadReports(projectFilename: string): Promise<ProjectReports> {
fileRef = new JSONFile<ProjectReports>(getPathToReports(projectFilename));
export async function loadReports(projectFilename: string): Promise<ShowRun[]> {
const dir = getPathToReports(projectFilename);
projectDir = dir;
failedWriteAttempts = 0;
cache = [];
try {
const maybeReports = await fileRef.read();
cache = isProjectReports(maybeReports) ? maybeReports : emptyStore();
const entries = await readDirectoryEntries(dir);
const reports = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
const contents = await Promise.all(
reports.map(async (entry) => {
try {
return await new JSONFile<unknown>(join(dir, entry.name)).read();
} catch (_error) {
// a single unreadable report is skipped rather than losing the rest
return null;
}
}),
);
// files come off disk in arbitrary order, the list is presented newest first
cache = contents.filter(isShowRun).sort((a, b) => b.startedAt - a.startedAt);
} catch (_error) {
// a missing or corrupt sidecar is not worth interrupting a project load over
cache = emptyStore();
// a missing directory is the normal case for a project with no history
}
return cache;
}
/**
* Returns the runs held for the current project, newest first
*/
/** Runs held for the current project, newest first */
export function getRuns(): ShowRun[] {
return cache.runs;
return cache;
}
export function getRun(id: string): ShowRun | undefined {
return cache.runs.find((run) => run.id === id);
return cache.find((run) => run.id === id);
}
/**
* Inserts or replaces a run, keeping the list ordered newest first.
*
* Every write here is a whole finished report, which only happens when a run
* is finished or edited. Nothing writes on the cue path.
* Writes a single finished report.
* Only this run's file is touched, so the cost does not grow with history.
*/
export async function upsertRun(run: ShowRun): Promise<void> {
const index = cache.runs.findIndex((candidate) => candidate.id === run.id);
const index = cache.findIndex((candidate) => candidate.id === run.id);
if (index === -1) {
cache.runs.unshift(run);
cache.unshift(run);
} else {
cache.runs[index] = run;
cache[index] = run;
}
const dir = projectDir;
if (dir === null || failedWriteAttempts > 3) {
return;
}
try {
ensureDirectory(dir);
await new JSONFile<ShowRun>(join(dir, `${run.id}.json`)).write(run);
failedWriteAttempts = 0;
} catch (_error) {
failedWriteAttempts += 1;
}
await persist();
}
/**
@@ -88,13 +117,13 @@ export async function upsertRun(run: ShowRun): Promise<void> {
* @returns whether a run was found and removed
*/
export async function deleteRun(id: string): Promise<boolean> {
const index = cache.runs.findIndex((run) => run.id === id);
const index = cache.findIndex((run) => run.id === id);
if (index === -1) {
return false;
}
cache.runs.splice(index, 1);
await persist();
cache.splice(index, 1);
await removeRunFile(id);
return true;
}
@@ -103,42 +132,42 @@ export async function deleteRun(id: string): Promise<boolean> {
* @returns how many runs were removed
*/
export async function deleteRunsForRundown(rundownId: string): Promise<number> {
const before = cache.runs.length;
cache.runs = cache.runs.filter((run) => run.rundownId !== rundownId);
const doomed = cache.filter((run) => run.rundownId === rundownId);
cache = cache.filter((run) => run.rundownId !== rundownId);
const removed = before - cache.runs.length;
if (removed > 0) {
await persist();
}
return removed;
// separate files, so these can go at once
await Promise.all(doomed.map((run) => removeRunFile(run.id)));
return doomed.length;
}
/**
* Clears the run history of the current project
*/
/** Clears the run history of the current project */
export async function deleteAllRuns(): Promise<void> {
cache.runs = [];
await persist();
cache = [];
if (projectDir === null) {
return;
}
try {
await deleteDirectory(projectDir);
} catch (_error) {
// leftovers are harmless, they are filtered on next load
}
}
/**
* Removes a project's sidecar from disk.
* Reports are owned by their project and do not outlive it.
* Removes a project's reports.
* They are owned by the project and do not outlive it, so this is a single
* recursive delete of its directory.
*/
export async function deleteReportsForProject(projectFilename: string): Promise<void> {
const path = getPathToReports(projectFilename);
try {
if ((await statIfExists(path)) !== null) {
await deleteFile(path);
}
await deleteDirectory(getPathToReports(projectFilename));
} catch (_error) {
// a leftover sidecar is harmless, deleting the project must still succeed
// a leftover directory is harmless, deleting the project must still succeed
}
}
/**
* Moves a project's sidecar so run history follows a project rename
*/
/** Moves a project's reports so history follows a project rename */
export async function renameReportsForProject(originalFilename: string, newFilename: string): Promise<void> {
const originalPath = getPathToReports(originalFilename);
const newPath = getPathToReports(newFilename);
@@ -148,38 +177,33 @@ export async function renameReportsForProject(originalFilename: string, newFilen
return;
}
await dockerSafeRename(originalPath, newPath);
// keep the reference pointing at the file we just moved
if (fileRef) {
fileRef = new JSONFile<ProjectReports>(newPath);
if (projectDir === originalPath) {
projectDir = newPath;
}
} catch (_error) {
// losing history on rename is bad but not fatal, the project rename stands
}
}
/**
* Writes the cache to disk.
* Gives up after repeated failures so a broken disk cannot stall the runtime.
* @private
*/
async function persist(): Promise<void> {
if (fileRef === null || failedWriteAttempts > 3) {
/** @private */
async function removeRunFile(id: string): Promise<void> {
const path = getPathToRun(id);
if (path === null) {
return;
}
try {
await fileRef.write(cache);
failedWriteAttempts = 0;
if ((await statIfExists(path)) !== null) {
await deleteFile(path);
}
} catch (_error) {
failedWriteAttempts += 1;
// the run is already out of the cache, a stray file is filtered on load
}
}
/**
* Resets in-memory state, used when no project is loaded and in tests
*/
/** Resets in-memory state, used when no project is loaded and in tests */
export function resetStore(): void {
fileRef = null;
cache = emptyStore();
projectDir = null;
cache = [];
failedWriteAttempts = 0;
}
@@ -17,7 +17,7 @@ import {
import { millisToString, validatePlayback } from 'ontime-utils';
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { closeRun, triggerReportEntry } from '../../api-data/report/report.service.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { logger } from '../../classes/Logger.js';
@@ -523,6 +523,10 @@ class RuntimeService {
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState);
// stop is what the Go button does on the last event, where it reads
// "Finish". Ending playback is the operator ending the show, and that
// is what turns the run in progress into a report.
void closeRun();
triggerAutomations(TimerLifeCycle.onStop);
});
@@ -67,8 +67,3 @@ export type ShowRunSummary = Omit<ShowRun, 'report'>;
* and has no end.
*/
export type OpenRun = Omit<ShowRun, 'report' | 'summary' | 'endedAt'>;
/** Contents of a project's report sidecar file */
export type ProjectReports = {
runs: ShowRun[];
};
-1
View File
@@ -28,7 +28,6 @@ export type {
OntimeReport,
OntimeEventReport,
OpenRun,
ProjectReports,
RunSummary,
ShowRun,
ShowRunSummary,