diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 7bc7e836e..0fa8d71aa 100644 --- a/apps/client/src/common/api/report.ts +++ b/apps/client/src/common/api/report.ts @@ -60,14 +60,6 @@ export async function fetchOpenRun(options?: RequestOptions): Promise { - await axios.post(`${reportUrl}/runs/finish`); - await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); -} - export async function renameRun(id: string, label: string): Promise { const res = await axios.patch(`${reportUrl}/runs/${id}`, { label }); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); diff --git a/apps/client/src/features/overview/composite/RunIndicator.module.scss b/apps/client/src/features/overview/composite/RunIndicator.module.scss index f8ea8bcbe..eb1e6828e 100644 --- a/apps/client/src/features/overview/composite/RunIndicator.module.scss +++ b/apps/client/src/features/overview/composite/RunIndicator.module.scss @@ -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 { diff --git a/apps/client/src/features/overview/composite/RunIndicator.tsx b/apps/client/src/features/overview/composite/RunIndicator.tsx index dfe22128d..38bbeee1f 100644 --- a/apps/client/src/features/overview/composite/RunIndicator.tsx +++ b/apps/client/src/features/overview/composite/RunIndicator.tsx @@ -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 ( -
- }> - - - {/* startedAt is a wall clock instant, not a time of day, so it is not formatTime's job */} - Recording since{' '} - - {new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' })} - - - - -
+ }> + + ); } diff --git a/apps/server/src/api-data/report/__tests__/report.service.test.ts b/apps/server/src/api-data/report/__tests__/report.service.test.ts index f11ecd1fb..5d905765b 100644 --- a/apps/server/src/api-data/report/__tests__/report.service.test.ts +++ b/apps/server/src/api-data/report/__tests__/report.service.test.ts @@ -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) => { diff --git a/apps/server/src/api-data/report/report.router.ts b/apps/server/src/api-data/report/report.router.ts index d34508444..402056c2f 100644 --- a/apps/server/src/api-data/report/report.router.ts +++ b/apps/server/src/api-data/report/report.router.ts @@ -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; diff --git a/apps/server/src/services/report-service/__tests__/report.store.test.ts b/apps/server/src/services/report-service/__tests__/report.store.test.ts index 8026e9d6c..b645aadfb 100644 --- a/apps/server/src/services/report-service/__tests__/report.store.test.ts +++ b/apps/server/src/services/report-service/__tests__/report.store.test.ts @@ -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(); vi.mock('lowdb/node', () => { @@ -24,18 +26,35 @@ vi.mock('../../../utils/fileManagement.js', async () => { const actual = await vi.importActual( '../../../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 { 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 { }; } +/** 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(); }); }); diff --git a/apps/server/src/services/report-service/report.parser.ts b/apps/server/src/services/report-service/report.parser.ts index 14d2f597b..b850e56d9 100644 --- a/apps/server/src/services/report-service/report.parser.ts +++ b/apps/server/src/services/report-service/report.parser.ts @@ -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) + ); } diff --git a/apps/server/src/services/report-service/report.store.ts b/apps/server/src/services/report-service/report.store.ts index 180809ed2..4ac9b8998 100644 --- a/apps/server/src/services/report-service/report.store.ts +++ b/apps/server/src/services/report-service/report.store.ts @@ -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 | 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 { - fileRef = new JSONFile(getPathToReports(projectFilename)); +export async function loadReports(projectFilename: string): Promise { + 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(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 { - 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(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 { * @returns whether a run was found and removed */ export async function deleteRun(id: string): Promise { - 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 { * @returns how many runs were removed */ export async function deleteRunsForRundown(rundownId: string): Promise { - 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 { - 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 { - 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 { 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(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 { - if (fileRef === null || failedWriteAttempts > 3) { +/** @private */ +async function removeRunFile(id: string): Promise { + 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; } diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index ee6d0de58..8418c233e 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -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); }); diff --git a/packages/types/src/definitions/core/Report.type.ts b/packages/types/src/definitions/core/Report.type.ts index e25101016..4b0e00b73 100644 --- a/packages/types/src/definitions/core/Report.type.ts +++ b/packages/types/src/definitions/core/Report.type.ts @@ -67,8 +67,3 @@ export type ShowRunSummary = Omit; * and has no end. */ export type OpenRun = Omit; - -/** Contents of a project's report sidecar file */ -export type ProjectReports = { - runs: ShowRun[]; -}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3ceecbdc1..3cad96107 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -28,7 +28,6 @@ export type { OntimeReport, OntimeEventReport, OpenRun, - ProjectReports, RunSummary, ShowRun, ShowRunSummary,