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
14 changes: 13 additions & 1 deletion src/ui/screens/JobsActivityScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,19 @@ export function JobsActivityScreen(props: { sf: SfApi; onNavigate: (route: strin
const scheduleById = useMemo(() => new Map(schedules.map(schedule => [schedule.id, schedule])), [schedules]);
const snapshotByRun = useMemo(() => {
const values = Object.values(snapshots);
return new Map(scheduleRuns.map(run => [run.id, values.find(snapshot => snapshot.scheduleId === run.scheduleId && Math.abs(snapshot.capturedAt - run.completedAt) < 60_000)?.id]));
return new Map(scheduleRuns.map(run => {
let bestId: string | undefined;
let bestDelta = Infinity;
for (const snapshot of values) {
if (snapshot.scheduleId !== run.scheduleId) continue;
const delta = Math.abs(snapshot.capturedAt - run.startedAt);
if (delta < bestDelta) {
bestDelta = delta;
bestId = snapshot.id;
}
}
Comment on lines +77 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound snapshot matches to the actual schedule run

When retention has pruned an older run's snapshot, this unconditionally assigns that run the nearest surviving snapshot from the same schedule, regardless of how far apart their timestamps are. Consequently every historical run appears to have an “Open result” even when its result was deleted, and the link leads users to an unrelated run's snapshots. Match the run's capture timestamp (the scheduler records capturedAt from the same startedAt value) or retain a suitably strict maximum delta.

Useful? React with 👍 / 👎.

return [run.id, bestId];
}));
}, [snapshots, scheduleRuns]);

const activity = useMemo<ActivityRow[]>(() => {
Expand Down
20 changes: 15 additions & 5 deletions src/ui/screens/SavedJobsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { h } from 'preact';
import { h } from 'preact';
import type { VNode } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import type { ExportTemplate, ImportTemplate, SavedJob, ScheduledExport } from '../../core/types/storage';
Expand Down Expand Up @@ -30,12 +30,13 @@ export function SavedJobsScreen(props: {
const fileRef = useRef<HTMLInputElement>(null);

useEffect(() => {
chrome.storage.local.get(['savedJobs', 'exportTemplates', 'importTemplates', 'scheduledExports'], result => {
chrome.storage.local.get(['savedJobs', 'exportTemplates', 'importTemplates', 'scheduledExports', 'deletedLegacyJobIds'], result => {
const merged = mergeLegacyJobs(
(result.savedJobs as SavedJob[]) ?? [],
(result.exportTemplates as ExportTemplate[]) ?? [],
(result.importTemplates as ImportTemplate[]) ?? [],
(result.scheduledExports as ScheduledExport[]) ?? [],
(result.deletedLegacyJobIds as string[]) ?? [],
);
setJobs(merged);
chrome.storage.local.set({ savedJobs: merged });
Expand Down Expand Up @@ -195,10 +196,19 @@ export function SavedJobsScreen(props: {
setRenaming(null);
}}
/>
<ConfirmModal open={pendingDelete !== null} title="Delete saved job" confirmText="Delete" confirmTone="danger" onCancel={() => setPendingDelete(null)} onConfirm={() => {
if (pendingDelete) void persist(jobs.filter(job => job.id !== pendingDelete.id));
<ConfirmModal open={pendingDelete !== null} title="Delete saved job" confirmText="Delete" confirmTone="danger" onCancel={() => setPendingDelete(null)} onConfirm={async () => {
if (!pendingDelete) return;
const isLegacy = /^(export|import|schedule):/.test(pendingDelete.id);
await persist(jobs.filter(job => job.id !== pendingDelete.id));
if (isLegacy) {
const result = await chrome.storage.local.get('deletedLegacyJobIds');
const existing = (result.deletedLegacyJobIds as string[]) ?? [];
if (!existing.includes(pendingDelete.id)) {
await chrome.storage.local.set({ deletedLegacyJobIds: [...existing, pendingDelete.id] });
}
}
setPendingDelete(null);
}}><p>Delete {pendingDelete?.name} and its version history?</p></ConfirmModal>
}}><p>Delete &quot;{pendingDelete?.name}&quot; and its version history?</p></ConfirmModal>
</div>
);
}
20 changes: 15 additions & 5 deletions src/ui/screens/SnapshotCenterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ExportSnapshot, SavedExportFormat, ScheduledExport } from '../../c
import type { SfApi } from '../api/sf';
import { Icon } from '../components/Icon';
import { exportRecords, ensureCorrectExtension } from '../utils/export';
import { flattenRecord, deriveColumns } from '../utils/records';
import { forecastSnapshotStorage, formatStorageSize } from '../utils/scheduleForecast';
import { diffBaselineRecords, selectComparisonKey } from '../utils/localDataDiff';

Expand Down Expand Up @@ -71,21 +72,26 @@ export function SnapshotCenterScreen(props: {
}

async function compareWithLive(): Promise<void> {
if (!left || !props.tabId) return;
if (!left) return;
const schedule = scheduleById.get(left.scheduleId);
if (!schedule) return;
const targetOrgId = left.orgId ?? schedule.orgId;
if (!targetOrgId) {
setMessage('Cannot compare: snapshot has no org ID.');
return;
}
setMessage('Loading live org records…');
try {
const records: Record<string, unknown>[] = [];
let page = await props.sf.runQuery(schedule.soql, props.tabId);
let page = await props.sf.crossOrgQuery(targetOrgId, schedule.soql);
records.push(...(page.records ?? []));
while (page.nextRecordsUrl && records.length < 100_000) {
page = await props.sf.queryMore(page.nextRecordsUrl, props.tabId);
page = await props.sf.queryMore(page.nextRecordsUrl);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep pagination on the snapshot's target org

When the live query returns nextRecordsUrl, this continuation switches from CROSS_ORG_QUERY to SF_QUERY_MORE without a tabId. The latter resolves authentication from a Salesforce tab, so requests from the full-page extension can fail on page two; even if a tab is resolved, it may belong to a different org than targetOrgId. Live comparisons for multi-page results therefore fail or continue against the wrong org. Add a cross-org query-more path that retains targetOrgId for every page.

Useful? React with 👍 / 👎.

records.push(...(page.records ?? []));
}
setLiveRecords(records);
setRightId('');
setMessage(`Loaded ${records.length.toLocaleString()} live records.`);
setMessage(`Loaded ${records.length.toLocaleString()} live records from org ${targetOrgId}.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Live comparison failed.');
}
Expand Down Expand Up @@ -133,7 +139,11 @@ export function SnapshotCenterScreen(props: {
<button class="wl-buttonText" onClick={() => persistSnapshots({ ...snapshots, [snapshot.id]: { ...snapshot, pinned: !snapshot.pinned } })}>{snapshot.pinned ? 'Unpin' : 'Pin'}</button>
<button class="wl-buttonNeutral" onClick={() => { setLeftId(snapshot.id); setRightId(''); setLiveRecords(null); }}>Compare</button>
<select class="wl-select" aria-label={`Download format for ${schedule?.name ?? snapshot.id}`} value={format} onChange={event => setFormats(current => ({ ...current, [snapshot.id]: (event.currentTarget as HTMLSelectElement).value as SavedExportFormat }))}><option value="csv">CSV</option><option value="json">JSON</option><option value="excel">Excel</option><option value="xml">XML</option></select>
<button class="wl-buttonBrand" disabled={Boolean(snapshot.error)} onClick={() => exportRecords(snapshot.records, snapshot.columns, { format, filename: ensureCorrectExtension(`${schedule?.name ?? 'snapshot'}-${snapshot.capturedAt}`, format) })}>Download</button>
<button class="wl-buttonBrand" disabled={Boolean(snapshot.error)} onClick={() => {
const flat = snapshot.records.map(record => flattenRecord(record));
const columns = deriveColumns(flat);
return exportRecords(flat, columns, { format, filename: ensureCorrectExtension(`${schedule?.name ?? 'snapshot'}-${snapshot.capturedAt}`, format) });
}}>Download</button>
</div></div><div class="wl-cardSection"><div class="wl-muted">{new Date(snapshot.capturedAt).toLocaleString()} · org {snapshot.orgId ?? schedule?.orgId ?? 'unknown'}{snapshot.error ? ` · ${snapshot.error}` : ''}</div></div></article>;
})}</div>
)}
Expand Down
6 changes: 4 additions & 2 deletions src/ui/utils/savedJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ export function mergeLegacyJobs(
exports: ExportTemplate[],
imports: ImportTemplate[],
schedules: ScheduledExport[],
deletedLegacyIds: string[] = [],
): SavedJob[] {
const byId = new Map(existing.map(job => [job.id, job]));
const tombstones = new Set(deletedLegacyIds);
const byId = new Map(existing.filter(job => !tombstones.has(job.id)).map(job => [job.id, job]));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow deleted legacy jobs to be restored by import

After a migrated job is deleted, its ID remains tombstoned. If the user later imports a portable backup containing that same ID, importFile sees no collision and reports a successful import, but this filter removes the restored job the next time the screen mounts. Tombstones should suppress only regeneration from the legacy template/schedule arrays, or importing the job should clear/replace its tombstone.

Useful? React with 👍 / 👎.

for (const job of [...exports.map(jobFromExportTemplate), ...imports.map(jobFromImportTemplate), ...schedules.map(jobFromSchedule)]) {
if (!byId.has(job.id)) byId.set(job.id, job);
if (!tombstones.has(job.id) && !byId.has(job.id)) byId.set(job.id, job);
}
return Array.from(byId.values());
}
Expand Down
Loading