diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md index 15b0bb46d2..19c548d416 100644 --- a/apps/gittensory-miner-extension/README.md +++ b/apps/gittensory-miner-extension/README.md @@ -18,5 +18,8 @@ available for the current issue. ## Local ranked cache Laptop-mode installs can paste JSON from a miner `discover` run into the options page. The extension stores that list in -`chrome.storage.local.rankedCandidates` and looks up the current issue there. When no ranked signal is cached for the -current issue, the badge degrades gracefully by staying hidden. +`chrome.storage.local.rankedCandidates`, alongside a `chrome.storage.local.rankedCandidatesSavedAt` timestamp updated on +every save, and looks up the current issue there. When no ranked signal is cached for the current issue, the badge +degrades gracefully by staying hidden. The badge itself shows a "last synced" relative-time label (mirroring ORB's +shared `RefreshMeta` component's thresholds) so a contributor can tell how stale the pasted data is; the label is +omitted entirely for a cache saved before this field existed. diff --git a/apps/gittensory-miner-extension/background.js b/apps/gittensory-miner-extension/background.js index bb86400b93..98ea7b4444 100644 --- a/apps/gittensory-miner-extension/background.js +++ b/apps/gittensory-miner-extension/background.js @@ -39,7 +39,7 @@ async function loadIssueOpportunityContext(message) { }; } - const rankedCandidates = await loadRankedCandidates(); + const { rankedCandidates, savedAt } = await loadRankedCandidates(); const rankedEntry = badgeApi.lookupRankedOpportunity(rankedCandidates, repoFullName, message.issueNumber); if (!rankedEntry) { return { @@ -56,6 +56,7 @@ async function loadIssueOpportunityContext(message) { issueNumber: message.issueNumber, repoFullName, badge: badgeApi.formatOpportunityBadge(rankedEntry), + savedAt, status: "ready", }; } @@ -68,9 +69,14 @@ async function loadMinerExtensionSettings() { return { watchedRepos }; } +// Reads rankedCandidates alongside its savedAt sync timestamp (#5192). `savedAt` degrades to `null` +// (never NaN) when absent -- e.g. data written before this field existed, or storage was cleared. async function loadRankedCandidates() { - const stored = await chrome.storage.local.get({ rankedCandidates: [] }); - return Array.isArray(stored.rankedCandidates) ? stored.rankedCandidates : []; + const stored = await chrome.storage.local.get({ rankedCandidates: [], rankedCandidatesSavedAt: null }); + return { + rankedCandidates: Array.isArray(stored.rankedCandidates) ? stored.rankedCandidates : [], + savedAt: typeof stored.rankedCandidatesSavedAt === "number" ? stored.rankedCandidatesSavedAt : null, + }; } // Toolbar-icon badge (#5193). Reads `rankedCandidates` WITHOUT a default so `undefined` still means diff --git a/apps/gittensory-miner-extension/content.js b/apps/gittensory-miner-extension/content.js index 3822fb5e7d..4e2a8d7833 100644 --- a/apps/gittensory-miner-extension/content.js +++ b/apps/gittensory-miner-extension/content.js @@ -53,12 +53,13 @@ async function loadOpportunityBadge(container, target) { renderOpportunityBadge(container, response.payload); } -function renderOpportunityBadge(container, payload) { +function renderOpportunityBadge(container, payload, nowMs = Date.now()) { if (!payload?.watched || !payload?.badge) { container.remove(); return; } - const markup = badgeApi?.renderOpportunityBadgeMarkup?.(payload.badge); + const lastSyncedLabel = badgeApi?.formatLastSyncedLabel?.(payload.savedAt, nowMs) ?? null; + const markup = badgeApi?.renderOpportunityBadgeMarkup?.(payload.badge, lastSyncedLabel); if (!markup) { container.remove(); return; diff --git a/apps/gittensory-miner-extension/opportunity-badge.js b/apps/gittensory-miner-extension/opportunity-badge.js index 9a9c96aaa3..64b189ea72 100644 --- a/apps/gittensory-miner-extension/opportunity-badge.js +++ b/apps/gittensory-miner-extension/opportunity-badge.js @@ -45,6 +45,20 @@ function formatOpportunityBadge(entry) { }; } +// Mirrors ORB's shared RefreshMeta component's relative-time thresholds/format +// (packages/gittensory-ui-kit/src/utils.ts's relativeTimeFromNow: just now / Xm ago / Xh ago / Xd ago), +// reimplemented here because this content script ships unbundled and cannot import that package (#5192). +function formatLastSyncedLabel(savedAt, nowMs) { + if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) return null; + const deltaSeconds = Math.max(0, Math.floor((Number(nowMs) - savedAt) / 1000)); + if (deltaSeconds < 60) return "last synced just now"; + const minutes = Math.floor(deltaSeconds / 60); + if (minutes < 60) return `last synced ${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `last synced ${hours}h ago`; + return `last synced ${Math.floor(hours / 24)}d ago`; +} + function escapeOpportunityHtml(value) { return String(value).replace(/[&<>"']/g, (char) => { switch (char) { @@ -62,7 +76,7 @@ function escapeOpportunityHtml(value) { }); } -function renderOpportunityBadgeMarkup(badge) { +function renderOpportunityBadgeMarkup(badge, lastSyncedLabel) { if (!badge || typeof badge !== "object") return ""; return `
${escapeOpportunityHtml(badge.why)}
+ ${ + lastSyncedLabel + ? `${escapeOpportunityHtml(lastSyncedLabel)}
` + : "" + } `; } @@ -84,6 +103,7 @@ const opportunityBadgeApi = { scoreToTier, buildOpportunityWhy, formatOpportunityBadge, + formatLastSyncedLabel, escapeOpportunityHtml, renderOpportunityBadgeMarkup, }; diff --git a/apps/gittensory-miner-extension/options.js b/apps/gittensory-miner-extension/options.js index 694b006953..391d54a022 100644 --- a/apps/gittensory-miner-extension/options.js +++ b/apps/gittensory-miner-extension/options.js @@ -48,7 +48,7 @@ form.addEventListener("submit", async (event) => { const repos = parseWatchedRepos(watchedRepos.value); const rankedCandidates = parseRankedCandidatesJson(rankedCandidatesJson.value); await chrome.storage.sync.set({ watchedRepos: repos }); - await chrome.storage.local.set({ rankedCandidates }); + await chrome.storage.local.set({ rankedCandidates, rankedCandidatesSavedAt: Date.now() }); await refreshSettings(); showStatus( rankedCandidates.length > 0 diff --git a/apps/gittensory-miner-extension/styles.css b/apps/gittensory-miner-extension/styles.css index e2fc361df8..d5e30515f4 100644 --- a/apps/gittensory-miner-extension/styles.css +++ b/apps/gittensory-miner-extension/styles.css @@ -62,3 +62,9 @@ color: rgba(244, 247, 245, 0.78); margin: 0; } + +.gittensory-miner-opportunity-badge__synced { + color: rgba(244, 247, 245, 0.55); + font-size: 11px; + margin: 6px 0 0; +} diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts index cef291fc30..9899237d9e 100644 --- a/test/unit/miner-extension-content.test.ts +++ b/test/unit/miner-extension-content.test.ts @@ -153,6 +153,145 @@ describe("miner extension opportunity badge", () => { expect(backgroundScript).not.toMatch(/discoveryIndexUrl/); }); + it("formats a relative 'last synced' label across the same buckets as ORB's RefreshMeta, clamping missing/invalid input to null (#5192)", () => { + const badge = loadBadgeInternals(); + const NOW_MS = Date.parse("2026-07-10T12:00:00.000Z"); + + expect(badge.formatLastSyncedLabel(NOW_MS, NOW_MS)).toBe("last synced just now"); + expect(badge.formatLastSyncedLabel(NOW_MS - 59_000, NOW_MS)).toBe("last synced just now"); + expect(badge.formatLastSyncedLabel(NOW_MS - 60_000, NOW_MS)).toBe("last synced 1m ago"); + expect(badge.formatLastSyncedLabel(NOW_MS - 59 * 60_000, NOW_MS)).toBe("last synced 59m ago"); + expect(badge.formatLastSyncedLabel(NOW_MS - 60 * 60_000, NOW_MS)).toBe("last synced 1h ago"); + expect(badge.formatLastSyncedLabel(NOW_MS - 23 * 60 * 60_000, NOW_MS)).toBe("last synced 23h ago"); + expect(badge.formatLastSyncedLabel(NOW_MS - 24 * 60 * 60_000, NOW_MS)).toBe("last synced 1d ago"); + expect(badge.formatLastSyncedLabel(NOW_MS + 5_000, NOW_MS)).toBe("last synced just now"); + + expect(badge.formatLastSyncedLabel(null, NOW_MS)).toBeNull(); + expect(badge.formatLastSyncedLabel(undefined, NOW_MS)).toBeNull(); + expect(badge.formatLastSyncedLabel(Number.NaN, NOW_MS)).toBeNull(); + expect(badge.formatLastSyncedLabel("not-a-timestamp", NOW_MS)).toBeNull(); + // Invariant: a falsy-but-coercible-to-0 value must never be read as "the epoch", i.e. a real timestamp. + expect(badge.formatLastSyncedLabel("", NOW_MS)).toBeNull(); + }); + + it("renders the last-synced label inside the badge markup when present, and omits it (no NaN, no crash) when absent (#5192)", () => { + const ranked = rankCandidateIssues([rawIssue()], { nowMs: NOW })[0]!; + const badge = loadBadgeInternals(); + const formatted = badge.formatOpportunityBadge(ranked); + + const withLabel = badge.renderOpportunityBadgeMarkup(formatted, "last synced 3m ago"); + expect(withLabel).toContain("last synced 3m ago"); + expect(withLabel).toContain(formatted.tier); + + const withoutLabel = badge.renderOpportunityBadgeMarkup(formatted, null); + expect(withoutLabel).not.toContain("last synced"); + expect(withoutLabel).not.toContain("NaN"); + // Invariant: adding/omitting the sync label never touches the ranking-derived fields. + expect(withoutLabel).toContain(formatted.tier); + expect(withoutLabel).toContain(formatted.score); + }); + + it("plumbs savedAt from background context through content.js into a rendered 'last synced' label (#5192)", () => { + const internals = loadContentInternals(); + const container = createMockContainer(); + const ranked = rankCandidateIssues([rawIssue()], { nowMs: NOW })[0]!; + const badge = loadBadgeInternals(); + const formatted = badge.formatOpportunityBadge(ranked); + const savedAt = NOW - 5 * 60_000; + + internals.renderOpportunityBadge(container, { watched: true, badge: formatted, savedAt, status: "ready" }, NOW); + expect(container.innerHTML).toContain("last synced 5m ago"); + }); + + it("regression: a cache saved before savedAt existed renders the badge without a sync label instead of crashing or showing NaN (#5192)", () => { + const internals = loadContentInternals(); + const container = createMockContainer(); + const ranked = rankCandidateIssues([rawIssue()], { nowMs: NOW })[0]!; + const badge = loadBadgeInternals(); + const formatted = badge.formatOpportunityBadge(ranked); + + internals.renderOpportunityBadge(container, { watched: true, badge: formatted, status: "ready" }, NOW); + expect(container.hidden).toBe(false); + expect(container.innerHTML).not.toContain("last synced"); + expect(container.innerHTML).not.toContain("NaN"); + }); + + it("includes savedAt in the ready background payload and omits it when there's no ranked signal (#5192)", async () => { + const ranked = rankCandidateIssues([rawIssue()], { nowMs: NOW }); + const savedAt = NOW - 60_000; + const ready = loadBackgroundInternals({ + watchedRepos: ["JSONbored/gittensory"], + rankedCandidates: ranked, + rankedCandidatesSavedAt: savedAt, + }); + const readyPayload = await ready.loadIssueOpportunityContext({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect(readyPayload.status).toBe("ready"); + expect(readyPayload.savedAt).toBe(savedAt); + + const noSignal = loadBackgroundInternals({ + watchedRepos: ["JSONbored/gittensory"], + rankedCandidates: [], + rankedCandidatesSavedAt: savedAt, + }); + const noSignalPayload = await noSignal.loadIssueOpportunityContext({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect(noSignalPayload.status).toBe("no-signal"); + expect(noSignalPayload.badge).toBeNull(); + }); + + it("writes a rankedCandidatesSavedAt timestamp alongside rankedCandidates on every save, including re-paste/overwrite (#5192)", async () => { + const localSetCalls: Array