diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss
new file mode 100644
index 000000000..b69696431
--- /dev/null
+++ b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.module.scss
@@ -0,0 +1,21 @@
+.chip {
+ background-color: $gray-1100;
+ white-space: nowrap;
+
+ font-size: calc(1rem - 3px);
+ color: $label-gray;
+ padding: 0.125rem 0.5rem;
+ border-radius: 2px;
+
+ &.over {
+ color: $playback-negative;
+ }
+
+ &.under {
+ color: $playback-ahead;
+ }
+
+ &.due {
+ color: $warning-orange;
+ }
+}
diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx
new file mode 100644
index 000000000..7cf9f2360
--- /dev/null
+++ b/apps/client/src/features/rundown/event-block/composite/EventBlockChip.tsx
@@ -0,0 +1,67 @@
+import { Tooltip } from '@chakra-ui/react';
+import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
+
+import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket';
+import { cx } from '../../../../common/utils/styleUtils';
+import { formatDuration } from '../../../../common/utils/time';
+import { tooltipDelayFast } from '../../../../ontimeConfig';
+
+import style from './EventBlockChip.module.scss';
+
+interface EventBlockChipProps {
+ id: string;
+ trueTimeStart: number;
+ isPast: boolean;
+ isLoaded: boolean;
+ className: string;
+ totalGap: number;
+ isLinkedAndNext: boolean;
+}
+
+export default function EventBlockChip(props: EventBlockChipProps) {
+ const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext } = props;
+ const { playback } = usePlayback();
+
+ if (isLoaded) {
+ return null; //TODO: the is a small flash of 'DUE' on the loaded event as clock data arrives before isLoaded propagates
+ }
+
+ const playbackActive = isPlaybackActive(playback);
+
+ if (!playbackActive || isPast) {
+ return null; //TODO: Event report will go here
+ }
+
+ if (playbackActive) {
+ // we extracted the component to avoid unnecessary calculations and re-renders
+ return (
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
+
+interface EventUntilProps {
+ trueTimeStart: number;
+ totalGap: number;
+ isLinkedAndNext: boolean;
+}
+
+function EventUntil(props: EventUntilProps) {
+ const { trueTimeStart, totalGap, isLinkedAndNext } = props;
+ const { clock, offset } = useTimelineStatus();
+
+ const consumedOffset = isLinkedAndNext ? offset : Math.min(offset + totalGap, 0);
+ const offsetTimestart = trueTimeStart - consumedOffset;
+ const timeUntil = offsetTimestart - clock;
+ const isDue = timeUntil < MILLIS_PER_SECOND;
+
+ const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
+
+ return
{timeUntilString}
;
+}
diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss
index efaea7b83..6254e8534 100644
--- a/apps/client/src/theme/_ontimeStyles.scss
+++ b/apps/client/src/theme/_ontimeStyles.scss
@@ -25,6 +25,7 @@ $ontime-delay-text: #E69056;
$ontime-paused: #c05621;
$ontime-stop: #E4281E;
$playback-negative: $red-500;
+$playback-ahead: $green-500;
$active-indicator: #8bb33d;
$text-black: $gray-1350;
diff --git a/e2e/tests/features/212-time-until.spec.ts b/e2e/tests/features/212-time-until.spec.ts
new file mode 100644
index 000000000..cf546b92a
--- /dev/null
+++ b/e2e/tests/features/212-time-until.spec.ts
@@ -0,0 +1,36 @@
+import { expect, test } from '@playwright/test';
+
+test('linked time until', async ({ page }) => {
+ await page.goto('http://localhost:4001/editor');
+
+ await page.getByRole('button', { name: 'Clear rundown' }).click();
+ await page.getByRole('button', { name: 'Delete all' }).click();
+
+ await page.getByRole('button', { name: 'Create Event' }).click();
+ await page.getByRole('button', { name: 'Event' }).nth(4).click();
+ await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
+ await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
+
+ await page.getByTestId('entry-1').getByLabel('Start event').click();
+ await page.getByLabel('Pause event').click();
+
+ await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m');
+ await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('19m');
+ await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('29m');
+
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').click();
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('6h');
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter');
+
+ await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5h59m');
+ await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('6h9m');
+ await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('6h19m');
+
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').click();
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('30s');
+ await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter');
+
+ await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('29s');
+ await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('10m');
+ await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('20m');
+});