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
7 changes: 5 additions & 2 deletions apps/gittensory-miner-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 9 additions & 3 deletions apps/gittensory-miner-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -56,6 +56,7 @@ async function loadIssueOpportunityContext(message) {
issueNumber: message.issueNumber,
repoFullName,
badge: badgeApi.formatOpportunityBadge(rankedEntry),
savedAt,
status: "ready",
};
}
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions apps/gittensory-miner-extension/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 21 additions & 1 deletion apps/gittensory-miner-extension/opportunity-badge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -62,7 +76,7 @@ function escapeOpportunityHtml(value) {
});
}

function renderOpportunityBadgeMarkup(badge) {
function renderOpportunityBadgeMarkup(badge, lastSyncedLabel) {
if (!badge || typeof badge !== "object") return "";
return `
<div class="gittensory-miner-opportunity-badge__header">
Expand All @@ -75,6 +89,11 @@ function renderOpportunityBadgeMarkup(badge) {
<span>${escapeOpportunityHtml(badge.score)}</span>
</div>
<p class="gittensory-miner-opportunity-badge__why">${escapeOpportunityHtml(badge.why)}</p>
${
lastSyncedLabel
? `<p class="gittensory-miner-opportunity-badge__synced">${escapeOpportunityHtml(lastSyncedLabel)}</p>`
: ""
}
`;
}

Expand All @@ -84,6 +103,7 @@ const opportunityBadgeApi = {
scoreToTier,
buildOpportunityWhy,
formatOpportunityBadge,
formatLastSyncedLabel,
escapeOpportunityHtml,
renderOpportunityBadgeMarkup,
};
Expand Down
2 changes: 1 addition & 1 deletion apps/gittensory-miner-extension/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions apps/gittensory-miner-extension/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
155 changes: 152 additions & 3 deletions test/unit/miner-extension-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
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<string, unknown> = {
__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<string, unknown>) => {
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<string, unknown> = {
watchedRepos: [],
Expand Down Expand Up @@ -251,7 +390,11 @@ function loadBadgeInternals() {
return vmContext.__gittensoryMinerOpportunityBadgeTestExports as {
lookupRankedOpportunity: (ranked: unknown[], repoFullName: string, issueNumber: number) => Record<string, unknown> | null;
formatOpportunityBadge: (entry: Record<string, unknown>) => { 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;
};
}

Expand All @@ -275,13 +418,18 @@ function loadContentInternals() {
matchGitHubIssueTarget: (
pathname: string,
) => { kind: "issue"; owner: string; repo: string; issueNumber: number } | null;
renderOpportunityBadge: (container: ReturnType<typeof createMockContainer>, payload: unknown) => void;
renderOpportunityBadge: (
container: ReturnType<typeof createMockContainer>,
payload: unknown,
nowMs?: number,
) => void;
};
}

function loadBackgroundInternals({
watchedRepos = [] as string[],
rankedCandidates = [] as unknown[],
rankedCandidatesSavedAt = null as number | null,
} = {}) {
const context: Record<string, unknown> = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
Expand All @@ -291,7 +439,7 @@ function loadBackgroundInternals({
get: async () => ({ watchedRepos }),
},
local: {
get: async () => ({ rankedCandidates }),
get: async () => ({ rankedCandidates, rankedCandidatesSavedAt }),
},
},
runtime: { onMessage: { addListener: () => {} } },
Expand All @@ -310,6 +458,7 @@ function loadBackgroundInternals({
}) => Promise<{
status: string;
badge: { tier: string; why: string } | null;
savedAt?: number | null;
}>;
};
}
Expand Down