refactor: restructure model to contain an object of rundowns

This commit is contained in:
Carlos Valente
2025-03-15 09:04:33 +01:00
committed by arc-alex
parent 0a80d6db31
commit abd9b127db
111 changed files with 4357 additions and 4515 deletions
+5 -3
View File
@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -40,9 +40,11 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundown, customFields } = data;
const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
+5 -6
View File
@@ -1,16 +1,11 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, OntimeRundown } from 'ontime-types';
import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* upload Excel file to server
* @return string - file ID op the uploaded file
@@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data;
}
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
+21 -6
View File
@@ -1,29 +1,44 @@
import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
import {
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(`${rundownPath}/`);
return res.data;
}
/**
* HTTP request to fetch all events
*/
export async function fetchNormalisedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownPath}/normalised`);
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
return res.data;
}
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data);
}
/**
* HTTP request to put new event
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
export async function requestPutEvent(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data);
}
@@ -48,7 +63,7 @@ export type ReorderEntry = {
/**
* HTTP request to reorder events
*/
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
rundown: OntimeRundown;
rundown: Rundown;
customFields: CustomFields;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
@@ -1,23 +1,29 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { OntimeEntry, Rundown } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown';
import { fetchCurrentRundown } from '../api/rundown';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
entries: {},
revision: -1,
};
/**
* Normalised rundown data
*/
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown,
queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
@@ -37,16 +43,16 @@ export function useFlatRundown() {
const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown);
const flatRundown = data.order.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
}
}, [data.order, data.revision, data.rundown, prevRevision]);
}, [data.entries, data.order, data.revision, prevRevision]);
// TODO: should we have a project id field?
// invalidate current version if project changes
@@ -57,13 +63,13 @@ export function useFlatRundown() {
}
}, [projectData]);
return { data: flatRunDown, status };
return { data: flatRundown, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
const { data, status } = useFlatRundown();
const filteredData = useMemo(() => {
return data.filter(cb);
+76 -45
View File
@@ -2,11 +2,12 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
isOntimeEvent,
MaybeString,
OntimeBlock,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeRundownEntry,
RundownCached,
Rundown,
TimeField,
TimeStrategy,
TransientEventPayload,
@@ -31,12 +32,12 @@ import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options to any new block (event / delay / block)
after: string;
before: string;
after: MaybeString;
before: MaybeString;
// options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
lastEventId: MaybeString;
}>;
/**
@@ -57,11 +58,11 @@ export const useEventAction = () => {
const getEventById = useCallback(
(eventId: string) => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.rundown) {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.entries) {
return;
}
return cachedRundown.rundown[eventId];
return cachedRundown.entries[eventId];
},
[queryClient],
);
@@ -100,9 +101,8 @@ export const useEventAction = () => {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
const previousEvent = rundown[applicationOptions.lastEventId];
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
}
@@ -180,15 +180,21 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const eventId = newEvent.id;
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.rundown };
const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
entries: newRundown,
revision: -1,
});
}
// Return a context with the previous and new events
@@ -196,7 +202,7 @@ export const useEventAction = () => {
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -210,7 +216,7 @@ export const useEventAction = () => {
* Updates existing event
*/
const updateEvent = useCallback(
async (event: Partial<OntimeRundownEntry>) => {
async (event: Partial<OntimeEntry>) => {
try {
await _updateEventMutation.mutateAsync(event);
} catch (error) {
@@ -296,9 +302,9 @@ export const useEventAction = () => {
* Utility function to get the previous event end time
*/
function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) {
if (!cachedRundown?.order || !cachedRundown?.entries) {
return 0;
}
@@ -308,7 +314,7 @@ export const useEventAction = () => {
}
let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]];
const event = cachedRundown.entries[cachedRundown.order[i]];
if (isOntimeEvent(event)) {
previousEnd = event.timeEnd;
break;
@@ -331,11 +337,11 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousEvents) {
if (previousRundown) {
const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown };
const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
@@ -349,16 +355,22 @@ export const useEventAction = () => {
}
});
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousRundown.id,
title: previousRundown.title,
order: previousRundown.order,
entries: newRundown,
revision: -1,
});
}
// Return a context with the previous and new events
return { previousEvents };
// Return a context with the previous rundown
return { previousRundown };
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
},
networkMode: 'always',
});
@@ -386,19 +398,21 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.rundown };
const newRundown = { ...previousData.entries };
for (const eventId of eventIds) {
delete newRundown[eventId];
}
queryClient.setQueryData(RUNDOWN, {
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: newOrder,
rundown: newRundown,
entries: newRundown,
revision: -1,
});
}
@@ -409,7 +423,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -445,10 +459,16 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
// optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData?.id ?? 'default',
title: previousData?.title ?? '',
entries: {},
order: [],
revision: -1,
});
// Return a context with the previous and new events
return { previousData };
@@ -456,7 +476,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -516,13 +536,18 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: newOrder,
entries: previousData.entries,
revision: -1,
});
}
// Return a context with the previous and new events
@@ -531,7 +556,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -572,22 +597,28 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
// optimistically update object
const newRundown = { ...previousData.rundown };
const eventA = previousData.rundown[from];
const eventB = previousData.rundown[to];
const newRundown = { ...previousData.entries };
const eventA = previousData.entries[from];
const eventB = previousData.entries[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
const { newA, newB } = swapEventData(eventA, eventB);
const [newA, newB] = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
entries: newRundown,
revision: -1,
});
}
// Return a context with the previous events
@@ -596,7 +627,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -1,4 +1,6 @@
import { makeCSVFromArrayOfArrays } from '../csv';
import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => {
@@ -11,3 +13,32 @@ after newline,after comma
`);
});
});
describe('aggregateRundowns()', () => {
it('flattens an object of rundowns into a single array', () => {
const rundowns = {
first: {
id: '',
title: '',
revision: 0,
order: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
},
} as Rundown,
} as ProjectRundowns;
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
});
});
@@ -1,4 +1,4 @@
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
@@ -29,7 +29,7 @@ describe('cloneEvent()', () => {
gap: 0,
custom: {
lighting: '3',
} as EventCustomFields,
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy()
})
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
});
it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123')
})
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
});
});
});
@@ -83,25 +83,26 @@ describe('generatePathFromPreset()', () => {
});
test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123',
);
});
});
describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => {
it('checks whether the paths match', () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
})
});
it("checks whether the params match", () => {
it('checks whether the params match', () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
})
});
it("considers edge cases for the url sharing feature", () => {
it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
})
});
});
+24 -3
View File
@@ -1,10 +1,31 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/**
* @description Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
* Converts an array of arrays to a CSV file
*/
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays);
}
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
@@ -23,6 +23,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
currentBlock: event.currentBlock,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
+2 -2
View File
@@ -1,4 +1,4 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { Log, Rundown, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
@@ -203,7 +203,7 @@ export const connectSocket = () => {
invalidateAllCaches();
} else if (target === 'RUNDOWN') {
const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
const currentRevision = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });