Skip to content

Commit 670ff7c

Browse files
committed
refactor: extract shared utilities and constants from code review
- Extract mergeAndSaveIfCurrent() helper to deduplicate version-guarded saves - Add classifyError() to format-utils.js for consistent error categorization - Add buildProfileUrl() to url-builder.js, replacing duplicate implementations - Move concurrency limits (VISIBLE_COUNT, PRIORITY, BACKGROUND) to constants.js - Fix 'Chrome minimum' comment to 'Browser minimum'
1 parent b37d496 commit 670ff7c

6 files changed

Lines changed: 98 additions & 68 deletions

File tree

src/background/service-worker.js

Lines changed: 54 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
MESSAGE_TYPES,
1313
NOTIFICATION_TYPES,
1414
NOTIFICATION_TYPE_ICONS,
15+
CONCURRENCY,
1516
} from "../lib/constants.js";
16-
import { formatReason } from "../lib/format-utils.js";
17+
import { formatReason, classifyError } from "../lib/format-utils.js";
1718
import { buildNotificationUrl } from "../lib/url-builder.js";
1819
import { LRUCache, DEFAULT_LRU_CACHE_SIZE } from "../lib/lru-cache.js";
1920

@@ -279,6 +280,30 @@ function filterToCurrentlyStored(detailedNotifications, currentStoredNotificatio
279280
return detailedNotifications.filter((n) => currentStoredIds.has(n.id));
280281
}
281282

283+
/**
284+
* Merge notifications with current storage and save, guarded by fetch version.
285+
* Aborts if a newer fetch has started (before or during the async storage read).
286+
* @param {number} fetchVersion - Version of the fetch that produced these notifications
287+
* @param {Array} notifications - Notifications to save
288+
* @param {string} label - Log label for debugging
289+
* @returns {Promise<boolean>} true if saved, false if superseded
290+
*/
291+
async function mergeAndSaveIfCurrent(fetchVersion, notifications, label) {
292+
if (fetchVersion < notificationFetchVersion) {
293+
console.log(`Fetch #${fetchVersion} superseded before ${label}, skipping`);
294+
return false;
295+
}
296+
const currentStored = await storage.getNotifications();
297+
// Re-check after async read: user mark-as-read may have bumped the version
298+
if (fetchVersion < notificationFetchVersion) {
299+
console.log(`Fetch #${fetchVersion} superseded during ${label} storage read, skipping`);
300+
return false;
301+
}
302+
const safe = filterToCurrentlyStored(notifications, currentStored);
303+
await storage.setNotifications(safe);
304+
return true;
305+
}
306+
282307
/**
283308
* Check for new notifications
284309
*
@@ -414,23 +439,22 @@ async function checkNotifications() {
414439
}
415440
}
416441

417-
// Split into priority (first 10 visible) and background loading
418-
const VISIBLE_COUNT = 10; // Approximately one screen of notifications
419-
const priorityNotifications = notificationsNeedingDetails.slice(0, VISIBLE_COUNT);
420-
const backgroundNotifications = notificationsNeedingDetails.slice(VISIBLE_COUNT);
442+
// Split into priority (visible) and background loading
443+
const priorityNotifications = notificationsNeedingDetails.slice(0, CONCURRENCY.VISIBLE_COUNT);
444+
const backgroundNotifications = notificationsNeedingDetails.slice(CONCURRENCY.VISIBLE_COUNT);
421445

422446
console.log(
423447
`Loading details: ${priorityNotifications.length} priority, ${backgroundNotifications.length} background`,
424448
);
425449

426-
// Priority loading: First 10 notifications (visible on screen)
450+
// Priority loading: First visible notifications
427451
let priorityResults = []; // Define in outer scope for background logging
428452
if (priorityNotifications.length > 0) {
429453
priorityResults = await fetchWithConcurrencyLimit(
430454
priorityNotifications.map(({ notification: n, index }) =>
431455
createDetailFetchTask(n, index, detailedNotifications),
432456
),
433-
5, // Concurrency limit: 5 requests at a time
457+
CONCURRENCY.PRIORITY,
434458
);
435459

436460
// Log priority loading results
@@ -440,21 +464,16 @@ async function checkNotifications() {
440464
`Fetch #${currentFetchVersion} priority: ${prioritySuccess} loaded, ${priorityFailed} failed`,
441465
);
442466

443-
// Check if superseded before updating
444-
if (currentFetchVersion >= notificationFetchVersion) {
445-
// Merge with current storage to avoid overwriting user deletions
446-
const currentStored = await storage.getNotifications();
447-
// Re-check version after async read: a user mark-as-read may have bumped
448-
// notificationFetchVersion between the read and write, making our snapshot stale.
449-
if (currentFetchVersion >= notificationFetchVersion) {
450-
const safeDetailed = filterToCurrentlyStored(detailedNotifications, currentStored);
451-
452-
// Save priority notifications immediately
453-
await storage.setNotifications(safeDetailed);
454-
console.log(
455-
`Fetch #${currentFetchVersion} saved ${priorityNotifications.length} priority notifications`,
456-
);
457-
}
467+
// Merge with current storage and save (guarded by version check)
468+
const saved = await mergeAndSaveIfCurrent(
469+
currentFetchVersion,
470+
detailedNotifications,
471+
"priority save",
472+
);
473+
if (saved) {
474+
console.log(
475+
`Fetch #${currentFetchVersion} saved ${priorityNotifications.length} priority notifications`,
476+
);
458477
}
459478
}
460479

@@ -465,7 +484,7 @@ async function checkNotifications() {
465484
backgroundNotifications.map(({ notification: n, index }) =>
466485
createDetailFetchTask(n, index, detailedNotifications),
467486
),
468-
3, // Lower concurrency for background: 3 requests at a time
487+
CONCURRENCY.BACKGROUND,
469488
)
470489
.then(async (backgroundResults) => {
471490
// Check if a newer fetch has completed while we were fetching details
@@ -490,27 +509,15 @@ async function checkNotifications() {
490509
`Author cache: ${cacheStats.size}/${cacheStats.maxSize} (${cacheStats.utilization})`,
491510
);
492511

493-
// Double-check before final save
494-
if (currentFetchVersion >= notificationFetchVersion) {
495-
// Merge with current storage to avoid overwriting user deletions
496-
const currentStoredNotifications = await storage.getNotifications();
497-
// Re-check version after async read: a user mark-as-read may have bumped
498-
// notificationFetchVersion between the read and write, making our snapshot stale.
499-
if (currentFetchVersion >= notificationFetchVersion) {
500-
const safeDetailed = filterToCurrentlyStored(
501-
detailedNotifications,
502-
currentStoredNotifications,
503-
);
504-
505-
// Update storage with all completed details
506-
await storage.setNotifications(safeDetailed);
507-
console.log(
508-
`Fetch #${currentFetchVersion} updated storage with detailed notifications`,
509-
);
510-
}
511-
} else {
512+
// Merge with current storage and save (guarded by version check)
513+
const saved = await mergeAndSaveIfCurrent(
514+
currentFetchVersion,
515+
detailedNotifications,
516+
"background save",
517+
);
518+
if (saved) {
512519
console.log(
513-
`Fetch #${currentFetchVersion} superseded before final save, skipping storage update`,
520+
`Fetch #${currentFetchVersion} updated storage with detailed notifications`,
514521
);
515522
}
516523
})
@@ -529,27 +536,21 @@ async function checkNotifications() {
529536
console.error(`Failed to check notifications (fetch #${currentFetchVersion}):`, error);
530537

531538
// Handle different error types with appropriate UI feedback
532-
if (error.message && error.message.includes("Rate limited")) {
533-
// Rate limited - show timer badge with reset info
539+
const errorType = classifyError(error);
540+
if (errorType === "rate-limited") {
534541
const rateLimitInfo = github.getRateLimitInfo();
535542
await action.setBadgeText({ text: "⏱" });
536543
await action.setBadgeBackgroundColor({ color: BADGE_COLORS.RATE_LIMITED });
537544
await action.setTitle({
538545
title: `Rate limited. Resets ${rateLimitInfo.resetIn || "soon"}`,
539546
});
540-
} else if (error.message && error.message.includes("timeout")) {
541-
// Network timeout
547+
} else if (errorType === "timeout") {
542548
await action.setBadgeText({ text: "⏱" });
543549
await action.setBadgeBackgroundColor({ color: BADGE_COLORS.TIMEOUT });
544550
await action.setTitle({ title: "Request timeout - will retry" });
545-
} else if (
546-
error.message &&
547-
(error.message.includes("NetworkError") || error.message.includes("Failed to fetch"))
548-
) {
549-
// Network error - keep last known state, update title only
551+
} else if (errorType === "offline") {
550552
await action.setTitle({ title: "Offline - showing cached data" });
551553
} else {
552-
// Other errors
553554
console.error("Unexpected error:", error);
554555
await action.setTitle({ title: `Error: ${error.message}` });
555556
}

src/lib/constants.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
// Alarm Configuration
77
export const ALARM_NAME = "check-notifications";
8-
export const DEFAULT_POLL_INTERVAL_MINUTES = 1; // Chrome minimum is 1 minute
8+
export const DEFAULT_POLL_INTERVAL_MINUTES = 1; // Browser minimum is 1 minute
99

1010
// API Configuration
1111
export const GITHUB_API_BASE = "https://api.github.com";
@@ -42,6 +42,13 @@ export const API_TIMEOUTS = {
4242
RETRY_REQUEST_BASE_DELAY: 500,
4343
};
4444

45+
// Concurrency Configuration
46+
export const CONCURRENCY = {
47+
PRIORITY: 5, // Concurrent requests for visible notifications
48+
BACKGROUND: 3, // Concurrent requests for remaining notifications
49+
VISIBLE_COUNT: 10, // Approximately one screen of notifications
50+
};
51+
4552
// Time Conversion
4653
export const TIME_CONVERSION = {
4754
MS_PER_SECOND: 1000,

src/lib/format-utils.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,16 @@ export function escapeHtml(text) {
9494
.replace(/"/g, "&quot;")
9595
.replace(/'/g, "&#39;");
9696
}
97+
98+
/**
99+
* Classify a network/API error into a category for consistent handling
100+
* @param {Error} error - Error object
101+
* @returns {'rate-limited'|'timeout'|'offline'|'unknown'} Error category
102+
*/
103+
export function classifyError(error) {
104+
const msg = error?.message || "";
105+
if (msg.includes("Rate limited")) return "rate-limited";
106+
if (msg.includes("timeout")) return "timeout";
107+
if (msg.includes("NetworkError") || msg.includes("Failed to fetch")) return "offline";
108+
return "unknown";
109+
}

src/lib/url-builder.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,13 @@ function buildVulnerabilityUrl(fullName) {
137137
function buildDependabotUrl(fullName) {
138138
return `${GITHUB_SITE_BASE}/${fullName}/security/dependabot`;
139139
}
140+
141+
/**
142+
* Build GitHub profile URL from a username/login
143+
* @param {string|null|undefined} login - GitHub username
144+
* @returns {string|null} Profile URL or null if login is falsy
145+
*/
146+
export function buildProfileUrl(login) {
147+
if (!login) return null;
148+
return `${GITHUB_SITE_BASE}/${encodeURIComponent(login)}`;
149+
}

src/popup/notification-renderer.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "../lib/constants.js";
1111
import { formatReason, getNotificationStatus } from "../lib/format-utils.js";
1212
import { getIconSVGElement } from "../lib/icons.js";
13+
import { buildProfileUrl } from "../lib/url-builder.js";
1314

1415
/**
1516
* Build icon class with state information
@@ -51,10 +52,7 @@ export function truncateReleaseBody(body, maxLength = 200) {
5152
return trimmed.substring(0, maxLength);
5253
}
5354

54-
function buildAuthorProfileUrl(login) {
55-
if (!login) return null;
56-
return `https://github.com/${encodeURIComponent(login)}`;
57-
}
55+
// Author profile URL construction delegated to url-builder.js (buildProfileUrl)
5856

5957
// Cache for notifications to avoid unnecessary re-renders
6058
let cachedNotifications = null;
@@ -141,7 +139,7 @@ export function createHoverCard(notif) {
141139
const hasAuthor = notif.author?.login;
142140
const hasComments = notif.comment_count > 0;
143141
const hasDescription = notif.body?.trim();
144-
const authorProfileUrl = hasAuthor ? buildAuthorProfileUrl(notif.author.login) : null;
142+
const authorProfileUrl = hasAuthor ? buildProfileUrl(notif.author.login) : null;
145143

146144
const card = document.createElement("div");
147145
card.className = "notification-hover-card";
@@ -284,7 +282,7 @@ function createNotificationItem(notif, repoHeader, repoFullName) {
284282
const releaseBody =
285283
notif.type === NOTIFICATION_TYPES.RELEASE && notif.body ? notif.body.trim() : "";
286284
const truncatedBody = releaseBody ? truncateReleaseBody(releaseBody) : "";
287-
const authorProfileUrl = notif.author?.login ? buildAuthorProfileUrl(notif.author.login) : null;
285+
const authorProfileUrl = notif.author?.login ? buildProfileUrl(notif.author.login) : null;
288286

289287
// Create notification icon container
290288
const iconDiv = document.createElement("div");

src/popup/popup.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
TIMING_THRESHOLDS,
1616
} from "../lib/constants.js";
1717
import { applyTheme } from "../lib/theme.js";
18+
import { buildProfileUrl } from "../lib/url-builder.js";
19+
import { classifyError } from "../lib/format-utils.js";
1820
import {
1921
initRenderer,
2022
renderNotifications,
@@ -47,7 +49,7 @@ function buildUserProfileUrl(username, userInfo) {
4749
// Fallback to building URL from username (GitHub.com only)
4850
const login = username || userInfo?.login;
4951
if (!login || login === "User") return null;
50-
return `https://github.com/${encodeURIComponent(login)}`;
52+
return buildProfileUrl(login);
5153
}
5254

5355
/**
@@ -628,17 +630,16 @@ async function refresh() {
628630
let message;
629631
let className = "error-message";
630632

631-
if (
632-
!navigator.onLine ||
633-
error.message?.includes("NetworkError") ||
634-
error.message?.includes("Failed to fetch")
635-
) {
633+
// Use shared error classification (also used by service-worker)
634+
const errorType = classifyError(error);
635+
636+
if (!navigator.onLine || errorType === "offline") {
636637
message = "⚠️ Offline - showing cached notifications";
637638
className = "offline-message";
638-
} else if (error.message?.includes("timeout")) {
639+
} else if (errorType === "timeout") {
639640
message = "⏱ Request timeout - showing cached data";
640641
className = "warning-message";
641-
} else if (error.message?.includes("Rate limited")) {
642+
} else if (errorType === "rate-limited") {
642643
message = "⏱ Rate limited - will retry automatically";
643644
className = "warning-message";
644645
} else {

0 commit comments

Comments
 (0)