Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ export function formatRelativeDate(

const targetDate = typeof value === 'string' ? new SvelteDate(value) : value;
const diffMs = targetDate.getTime() - config.now.getTime();
const isPast = diffMs < 0;
const isPast = diffMs <= 0;
const absDiffMs = Math.abs(diffMs);

const duration = formatDuration({milliseconds: absDiffMs}, config.smallestUnit, options, config.maxUnits);
if (!duration) return config.defaultValue;
const unit = config.smallestUnit ?? 'seconds';
// DurationFormat omits zero fields, so diffs < smallestUnit format as "". Force-display a zero.
const duration =
formatDuration({milliseconds: absDiffMs}, config.smallestUnit, options, config.maxUnits) ||
formatDuration({[unit]: 0}, unit, {...options, [`${unit}Display`]: 'always'});

return isPast ? gt`${duration} ago` : gt`in ${duration}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {describe, expect, it} from 'vitest';
import {formatRelativeDate} from './format-relative-date-fn.svelte';

function config(now: Date, smallestUnit: 'milliseconds' | 'seconds' | 'minutes' | 'hours' = 'seconds') {
return {defaultValue: 'NEVER', now, smallestUnit};
}

describe('formatRelativeDate', () => {
it('returns defaultValue for nullish input', () => {
const now = new Date();
expect(formatRelativeDate(null, undefined, config(now))).toBe('NEVER');
expect(formatRelativeDate(undefined, undefined, config(now))).toBe('NEVER');
});

it('formats a past duration', () => {
const now = new Date();
const result = formatRelativeDate(new Date(now.getTime() - 3000), undefined, config(now));
expect(result.startsWith('3 ')).toBe(true);
expect(result.endsWith(' ago')).toBe(true);
});

it('formats a future duration', () => {
const now = new Date();
const result = formatRelativeDate(new Date(now.getTime() + 3000), undefined, config(now));
expect(result.startsWith('in 3 ')).toBe(true);
});

it('falls back to a zero-duration string when diff is below smallestUnit', () => {
const now = new Date();
expect(formatRelativeDate(new Date(now.getTime() - 500), undefined, config(now))).toMatch(/^0 .+ ago$/);
expect(formatRelativeDate(new Date(now.getTime() + 500), undefined, config(now))).toMatch(/^in 0 /);
});

it('produces the correct plural for zero (style=long)', () => {
const now = new Date();
expect(formatRelativeDate(now, {style: 'long'}, config(now))).toContain('0 seconds');
});

it('accepts ISO-string dates', () => {
const now = new Date();
const earlier = new Date(now.getTime() - 3000).toISOString();
expect(formatRelativeDate(earlier, undefined, config(now)).startsWith('3 ')).toBe(true);
});
});
2 changes: 2 additions & 0 deletions frontend/viewer/src/lib/errors/global-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ function unifyErrorEvent(event: ErrorEvent | PromiseRejectionEvent): UnifiedErro

function shouldIgnoreError(message: string): boolean {
if (message.includes('Perhaps the DotNetObjectReference instance was already disposed')) return true;
// Blazor WebView completing a JS->.NET call whose JS-side registry was already torn down (e.g. page refresh).
if (message.includes('There is no pending async call with ID')) return true;
// Code (i.e. {expression}) inside a <MenuItem> slot, inside a portal causes this error if the portal is open while the screen is resized 🙃
// It's worth noting that in Lexbox we've also seen browser extensions trigger this error
if (message.includes('ResizeObserver loop completed with undelivered notifications')) return true;
Expand Down
46 changes: 32 additions & 14 deletions frontend/viewer/src/project/SyncDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import {useProjectContext} from '$project/project-context.svelte';
import SyncStatusPrimitive from './sync/SyncStatusPrimitive.svelte';
import ResponsiveDialog from '$lib/components/responsive-dialog/responsive-dialog.svelte';
import {useProjectEventBus} from '$lib/services/event-bus';

const {
syncStatus = SyncStatus.Success
Expand All @@ -24,6 +25,7 @@
const projectContext = useProjectContext();
const service = useSyncStatusService();
const features = useFeatures();
const projectEventBus = useProjectEventBus();
let remoteStatus = $state<IProjectSyncStatus>();
let localStatus = $state<IPendingCommits>();
let server = $derived(projectContext.server);
Expand All @@ -35,26 +37,46 @@

watch(() => openQueryParam.current, (newValue) => {
if (newValue) void onOpen();
else setTimeout(onClose, 500); // don't clear contents until close animation is done
});

export function open(): void {
openQueryParam.current = true;
}

async function onOpen(): Promise<void> {
await Promise.all([
let pendingRefresh: Promise<unknown> | undefined;
let rerunAfterPending = false;

// Catch background syncs (e.g. another client pushing changes) that onOpen alone wouldn't see.
// forceFresh: an in-flight refresh started before the sync may return pre-sync data, so queue a follow-up.
// Must follow pendingRefresh — onSync invokes the callback synchronously with a cached event.
projectEventBus.onSync(e => {
if (openQueryParam.current && e.status === SyncStatus.Success) void refreshStatus({forceFresh: true});
});

function refreshStatus(opts?: {forceFresh?: boolean}) {
if (pendingRefresh) {
if (opts?.forceFresh) rerunAfterPending = true;
return pendingRefresh;
}
pendingRefresh = Promise.all([
service.getLocalStatus().then(s => localStatus = s),
service.getStatus().then(s => remoteStatus = s),
service.getLatestSyncedCommitDate().then(s => latestSyncedCommitDate = s),
service.getCurrentServer().then(s => server = s),
]);
]).finally(() => {
pendingRefresh = undefined;
if (rerunAfterPending) {
rerunAfterPending = false;
void refreshStatus();
}
});
return pendingRefresh;
}

function onClose(): void {
localStatus = undefined;
remoteStatus = undefined;
latestSyncedCommitDate = undefined;
async function onOpen(): Promise<void> {
await Promise.all([
refreshStatus(),
service.getCurrentServer().then(s => server = s),
]);
}

async function syncLexboxToFlex() {
Expand Down Expand Up @@ -107,11 +129,7 @@
localStatus.remote = 0;
localStatus.local = 0;
}
await Promise.all([
service.getLocalStatus().then(s => localStatus = s),
service.getStatus().then(s => remoteStatus = s),
service.getLatestSyncedCommitDate().then(s => latestSyncedCommitDate = s),
]);
await refreshStatus();
}

function onLoginStatusChange(status: 'logged-in' | 'logged-out') {
Expand Down
Loading