From 27157902815023d468826478d35f141f18c73907 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Sun, 12 Jul 2026 20:18:34 -0400 Subject: [PATCH] feat(miner-extension): show a last-synced label on the opportunity badge Persist a savedAt timestamp alongside rankedCandidates in chrome.storage.local whenever the options page saves, and surface it as a relative-time "last synced" label on the GitHub issue-page opportunity badge, mirroring ORB's shared RefreshMeta thresholds. A cache saved before this field existed degrades gracefully by omitting the label instead of showing NaN. Fixes #5192 --- apps/gittensory-miner-extension/README.md | 7 +- apps/gittensory-miner-extension/background.js | 12 +- apps/gittensory-miner-extension/content.js | 5 +- .../opportunity-badge.js | 22 ++- apps/gittensory-miner-extension/options.js | 2 +- apps/gittensory-miner-extension/styles.css | 6 + test/unit/miner-extension-content.test.ts | 155 +++++++++++++++++- 7 files changed, 197 insertions(+), 12 deletions(-) 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 `
@@ -75,6 +89,11 @@ function renderOpportunityBadgeMarkup(badge) { ${escapeOpportunityHtml(badge.score)}

${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> = []; + let fakeNowMs = Date.parse("2026-07-10T12:00:00.000Z"); + const elements = { + "#settings": createFormMock(), + "#status": { textContent: "" }, + "#watchedRepos": { value: "JSONbored/gittensory" }, + "#rankedCandidatesJson": { value: "[]" }, + }; + const context: Record = { + __GITTENSORY_MINER_EXTENSION_TEST__: true, + Date: { now: () => fakeNowMs }, + document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null }, + chrome: { + storage: { + sync: { get: async () => ({ watchedRepos: [] }), set: async () => {}, remove: async () => {} }, + local: { + get: async () => ({ rankedCandidates: [] }), + set: async (value: Record) => { + localSetCalls.push(value); + }, + }, + }, + }, + window: { setTimeout: () => 0 }, + }; + context.globalThis = context; + const vmContext = createContext(context); + new Script(optionsScript).runInContext(vmContext); + await flushPromises(); + + await elements["#settings"].dispatchSubmit(); + fakeNowMs = Date.parse("2026-07-10T12:05:00.000Z"); + await elements["#settings"].dispatchSubmit(); + + expect(localSetCalls).toHaveLength(2); + expect(localSetCalls[0]).toEqual({ + rankedCandidates: [], + rankedCandidatesSavedAt: Date.parse("2026-07-10T12:00:00.000Z"), + }); + expect(localSetCalls[1]).toEqual({ + rankedCandidates: [], + rankedCandidatesSavedAt: Date.parse("2026-07-10T12:05:00.000Z"), + }); + }); + it("purges a discoveryIndexUrl value already synced by an older extension version, on load and on save", async () => { const synced: Record = { watchedRepos: [], @@ -251,7 +390,11 @@ function loadBadgeInternals() { return vmContext.__gittensoryMinerOpportunityBadgeTestExports as { lookupRankedOpportunity: (ranked: unknown[], repoFullName: string, issueNumber: number) => Record | null; formatOpportunityBadge: (entry: Record) => { tier: string; score: string; why: string }; - renderOpportunityBadgeMarkup: (badge: { tier: string; score: string; why: string }) => string; + formatLastSyncedLabel: (savedAt: unknown, nowMs: number) => string | null; + renderOpportunityBadgeMarkup: ( + badge: { tier: string; score: string; why: string }, + lastSyncedLabel?: string | null, + ) => string; }; } @@ -275,13 +418,18 @@ function loadContentInternals() { matchGitHubIssueTarget: ( pathname: string, ) => { kind: "issue"; owner: string; repo: string; issueNumber: number } | null; - renderOpportunityBadge: (container: ReturnType, payload: unknown) => void; + renderOpportunityBadge: ( + container: ReturnType, + payload: unknown, + nowMs?: number, + ) => void; }; } function loadBackgroundInternals({ watchedRepos = [] as string[], rankedCandidates = [] as unknown[], + rankedCandidatesSavedAt = null as number | null, } = {}) { const context: Record = { __GITTENSORY_MINER_EXTENSION_TEST__: true, @@ -291,7 +439,7 @@ function loadBackgroundInternals({ get: async () => ({ watchedRepos }), }, local: { - get: async () => ({ rankedCandidates }), + get: async () => ({ rankedCandidates, rankedCandidatesSavedAt }), }, }, runtime: { onMessage: { addListener: () => {} } }, @@ -310,6 +458,7 @@ function loadBackgroundInternals({ }) => Promise<{ status: string; badge: { tier: string; why: string } | null; + savedAt?: number | null; }>; }; }