diff --git a/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts
index e61d0498ea3..39f18dd1358 100644
--- a/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts
+++ b/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts
@@ -1,5 +1,6 @@
import { render } from '@testing-library/vue';
import { register } from 'timeago.js';
+import { nextTick } from 'vue';
import N8nTimeAgo from './TimeAgo.vue';
@@ -54,4 +55,38 @@ describe('N8nTimeAgo', () => {
'22 September, 2026 @ 11:57',
);
});
+
+ it('ages a live label once per interval', async () => {
+ const { container } = render(N8nTimeAgo, { props: { date: NOW.toISOString(), live: true } });
+ expect(container.textContent?.trim()).toBe('just now');
+
+ vi.advanceTimersByTime(29 * 1000);
+ await nextTick();
+ expect(container.textContent?.trim()).toBe('just now');
+
+ vi.advanceTimersByTime(5 * 60 * 1000);
+ await nextTick();
+ expect(container.textContent?.trim()).toBe('5 minutes ago');
+ });
+
+ it('keeps a static label at its first value', async () => {
+ const { container } = render(N8nTimeAgo, { props: { date: NOW.toISOString() } });
+
+ vi.advanceTimersByTime(5 * 60 * 1000);
+ await nextTick();
+
+ expect(container.textContent?.trim()).toBe('just now');
+ });
+
+ it('reads a date set between two ticks as the past', async () => {
+ const { container, rerender } = render(N8nTimeAgo, {
+ props: { date: NOW.toISOString(), live: true },
+ });
+
+ // The reference date trails the clock by up to one tick, so a newer date must not read as the future.
+ vi.advanceTimersByTime(10 * 1000);
+ await rerender({ date: new Date().toISOString(), live: true });
+
+ expect(container.textContent?.trim()).toBe('just now');
+ });
});
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue b/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue
index d516dad3977..683db2cce14 100644
--- a/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue
+++ b/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue
@@ -1,7 +1,11 @@
-
+
diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts
index 8f7df8d7495..a049483fe7f 100644
--- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts
+++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts
@@ -114,6 +114,21 @@ describe('PromotionSelectModal', () => {
});
});
+ it('should show when the changes were last refreshed', async () => {
+ const { findByTestId, findByText } = renderComponent({
+ pinia,
+ props: {
+ modalName: PROMOTION_SELECT_MODAL_KEY,
+ data: { projectId: 'project-1', direction: 'promote' },
+ },
+ });
+ await findByText('Payment Handler');
+
+ expect(await findByTestId('promotion-last-refreshed')).toHaveTextContent(
+ 'Last refreshed just now',
+ );
+ });
+
it('should disable promote button when nothing selected', async () => {
const { findByTestId } = renderComponent({
pinia,
diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue
index 55262d6ee08..94036e5f0b0 100644
--- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue
+++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue
@@ -50,6 +50,7 @@ const {
isLoading,
error,
searchQuery,
+ lastRefreshedAt,
selectedIds,
selectedCount,
allSelected,
@@ -240,6 +241,16 @@ onMounted(async () => {
data-test-id="promotion-search"
:class="$style.searchInput"
/>
+
+ {{ i18n.baseText('promotions.modal.lastRefreshed') }}
+
+
{
margin-inline: calc(var(--input--padding) * -1) 0 0;
}
+.lastRefreshed {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
.loading {
display: flex;
justify-content: center;
diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts
index 0eda6019831..ba2c56303a2 100644
--- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts
+++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts
@@ -101,6 +101,31 @@ describe('usePromotionChanges', () => {
expect(isLoading.value).toBe(false);
});
+ it('should stamp the last refresh on success only', async () => {
+ vi.useFakeTimers();
+ try {
+ const { fetchChanges, lastRefreshedAt } = usePromotionChanges('project-1');
+ expect(lastRefreshedAt.value).toBeNull();
+
+ await fetchChanges();
+ const firstRefresh = lastRefreshedAt.value;
+ expect(firstRefresh).not.toBeNull();
+
+ vi.mocked(promotionsApi.getPromotableChanges).mockRejectedValueOnce(
+ new Error('Network error'),
+ );
+ await fetchChanges();
+ expect(lastRefreshedAt.value).toBe(firstRefresh);
+
+ // Two stamps in the same millisecond would compare equal, so move the clock first.
+ vi.advanceTimersByTime(60_000);
+ await fetchChanges();
+ expect(lastRefreshedAt.value).not.toBe(firstRefresh);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it('should select only the visible rows when a search filter is active', async () => {
const {
fetchChanges,
diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts
index 3819df97a64..0feea08e3c7 100644
--- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts
+++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts
@@ -11,6 +11,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec
const isLoading = ref(false);
const error = ref(null);
const searchQuery = ref('');
+ const lastRefreshedAt = ref(null);
const selectedIds = ref>(new Set());
@@ -49,6 +50,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec
changes.value = result.changes;
commitSha.value = result.commitSha;
reconcileSelection();
+ lastRefreshedAt.value = new Date().toISOString();
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e));
} finally {
@@ -84,6 +86,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec
isLoading,
error,
searchQuery,
+ lastRefreshedAt,
selectedIds,
selectedCount,
allSelected,