From 031b3f1ace14002f5241ab131a6c06c42d82ee11 Mon Sep 17 00:00:00 2001 From: Jay Mingle Date: Fri, 10 Jul 2026 01:21:36 +0000 Subject: [PATCH] Removed all job category aggregation, display dates job posted and minor fixes --- job-notifier/background.js | 89 ++++++++++++++---- job-notifier/popup.html | 27 +++--- job-notifier/popup.js | 184 +++++++++++++++++++------------------ 3 files changed, 178 insertions(+), 122 deletions(-) diff --git a/job-notifier/background.js b/job-notifier/background.js index 7e1df8b..71ead4a 100644 --- a/job-notifier/background.js +++ b/job-notifier/background.js @@ -435,6 +435,44 @@ async function fetchPostedDate(url) { } } +// Converts Andela's relative "Posted X ago" text into an absolute timestamp +// (ms since epoch), computed once at fetch time. Storing the absolute time +// instead of the display string lets the popup recompute "X ago" live, +// so it never goes stale. +// +// Andela uses a few different phrasings depending on how recent the post +// is: "Posted 10 hours ago", "Posted yesterday", "Posted today", etc. — +// not all of them have a number + unit + "ago", so those need explicit +// handling rather than relying purely on the regex. +function parsePostedAgoToTimestamp(text) { + if (!text) return null; + var cleaned = text.trim().toLowerCase(); + + if (/posted\s+today/.test(cleaned) || /posted\s+just now/.test(cleaned)) { + return Date.now(); + } + if (/posted\s+yesterday/.test(cleaned)) { + return Date.now() - 24 * 60 * 60 * 1000; + } + + var match = cleaned.match( + /posted\s+(\d+)\s+(minute|hour|day|week|month)s?\s+ago/i, + ); + if (!match) return null; + + var amount = parseInt(match[1], 10); + var unit = match[2].toLowerCase(); + var msPerUnit = { + minute: 60 * 1000, + hour: 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + month: 30 * 24 * 60 * 60 * 1000, + }; + + return Date.now() - amount * msPerUnit[unit]; +} + let keepAliveInterval = null; function startKeepAlive() { @@ -455,21 +493,21 @@ function stopKeepAlive() { } // Merges freshly scraped jobs into stored jobs, notifies on new opportunities, -// fetches posted dates, tracks a "new" badge window per job, and persists -// everything. Used by both check types. +// resolves posted-date timestamps, tracks a "new" badge window per job, and +// persists everything. Used by both check types. async function processScrapedJobs(freshJobs) { const stored = await getStoredData([ 'lastAndelaJobs', 'notifiedJobIds', - 'postedDates', + 'postedTimestamps', 'backfillDone', 'newBadgeUntil', ]); const notifiedJobIds = new Set(stored.notifiedJobIds || []); - const postedDates = stored.postedDates || {}; + const postedTimestamps = stored.postedTimestamps || {}; // { jobId: ms since epoch } const backfillDone = stored.backfillDone || false; const previousJobs = stored.lastAndelaJobs || []; - const newBadgeUntil = stored.newBadgeUntil || {}; // { jobId: expiryTimestampMs } + const newBadgeUntil = stored.newBadgeUntil || {}; // Merge: fresh scrape wins for any job id/kind it covers; anything not // re-scraped this cycle (e.g. other titles during a fast check) is kept @@ -483,21 +521,31 @@ async function processScrapedJobs(freshJobs) { const merged = dedupeJobs(sortJobsByRecency([...freshJobs, ...carriedOver])); merged.forEach((job) => { - if (postedDates[job.id]) job.posted_ago = postedDates[job.id]; + if (postedTimestamps[job.id]) + job.posted_timestamp = postedTimestamps[job.id]; }); if (!backfillDone) { const toBackfill = merged.filter( - (job) => job.kind === 'opportunity' && !postedDates[job.id], + (job) => job.kind === 'opportunity' && !postedTimestamps[job.id], ); log( 'Backfilling posted dates for ' + toBackfill.length + ' existing jobs...', ); for (const job of toBackfill) { - const postedDate = await fetchPostedDate(job.url); - if (postedDate) { - job.posted_ago = postedDate; - postedDates[job.id] = postedDate; + const postedText = await fetchPostedDate(job.url); + const timestamp = parsePostedAgoToTimestamp(postedText); + if (timestamp) { + job.posted_timestamp = timestamp; + postedTimestamps[job.id] = timestamp; + } else { + log( + 'Could not parse posted date for job ' + + job.id + + ': "' + + postedText + + '"', + ); } } } @@ -511,10 +559,19 @@ async function processScrapedJobs(freshJobs) { const badgeDurationMs = MULTI_TITLE_INTERVAL * 60 * 1000; for (const job of newJobs) { - const postedDate = await fetchPostedDate(job.url); - if (postedDate) { - job.posted_ago = postedDate; - postedDates[job.id] = postedDate; + const postedText = await fetchPostedDate(job.url); + const timestamp = parsePostedAgoToTimestamp(postedText); + if (timestamp) { + job.posted_timestamp = timestamp; + postedTimestamps[job.id] = timestamp; + } else { + log( + 'Could not parse posted date for job ' + + job.id + + ': "' + + postedText + + '"', + ); } await notifyNewJob(job); notifiedJobIds.add(job.id); @@ -528,7 +585,7 @@ async function processScrapedJobs(freshJobs) { await setStoredData({ notifiedJobIds: Array.from(notifiedJobIds), - postedDates: postedDates, + postedTimestamps: postedTimestamps, backfillDone: true, newBadgeUntil: newBadgeUntil, lastAndelaJobs: merged, diff --git a/job-notifier/popup.html b/job-notifier/popup.html index 1e7a30f..9248fda 100644 --- a/job-notifier/popup.html +++ b/job-notifier/popup.html @@ -29,21 +29,6 @@ .section-heading-applied { color: #c0392b; } - .profession-heading { - font-size: 13px; - font-weight: 700; - margin: 10px 0 4px; - display: flex; - align-items: center; - gap: 6px; - } - .profession-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - } .job-item { padding: 10px; margin-bottom: 10px; @@ -62,6 +47,7 @@ .new-badge { display: inline-block; color: white; + background-color: #e03131; font-size: 10px; font-weight: 700; border-radius: 10px; @@ -79,12 +65,21 @@ font-size: 12px; margin-top: 3px; } - .job-posted { + .job-posted-row { + display: flex; + justify-content: space-between; + align-items: center; color: #0066cc; font-size: 11px; margin-top: 5px; font-weight: 500; } + .profession-tag { + color: #c9971c; + font-size: 11px; + font-weight: 700; + margin-top: 4px; + } .status { color: #666; font-size: 12px; diff --git a/job-notifier/popup.js b/job-notifier/popup.js index d853b7a..e872cd2 100644 --- a/job-notifier/popup.js +++ b/job-notifier/popup.js @@ -1,18 +1,42 @@ // popup.js -var TITLE_ORDER = [ - 'Full-Stack Engineer', - 'AI Engineer', - 'Data Scientist', - 'Front-End Engineer', -]; - -var PROFESSION_BADGES = { - 'Full-Stack Engineer': { label: 'FS', color: '#4C6EF5' }, - 'AI Engineer': { label: 'AI', color: '#9C36B5' }, - 'Data Scientist': { label: 'DS', color: '#0CA678' }, - 'Front-End Engineer': { label: 'FE', color: '#E8590C' }, +var PROFESSION_COLORS = { + 'Full-Stack Engineer': '#4C6EF5', + 'AI Engineer': '#9C36B5', + 'Data Scientist': '#0CA678', + 'Front-End Engineer': '#E8590C', }; +var DEFAULT_PROFESSION_COLOR = '#C9971C'; + +// Recomputes "X ago" fresh every render from the stored absolute timestamp, +// so it's always accurate instead of a stale cached string. +function formatTimeAgo(timestamp) { + if (!timestamp) return ''; + var diffMs = Date.now() - timestamp; + if (diffMs < 0) diffMs = 0; + + var minutes = Math.floor(diffMs / (60 * 1000)); + var hours = Math.floor(diffMs / (60 * 60 * 1000)); + var days = Math.floor(diffMs / (24 * 60 * 60 * 1000)); + var weeks = Math.floor(days / 7); + var months = Math.floor(days / 30); + + if (minutes < 60) + return ( + 'Posted ' + + Math.max(minutes, 1) + + ' minute' + + (minutes === 1 ? '' : 's') + + ' ago' + ); + if (hours < 24) + return 'Posted ' + hours + ' hour' + (hours === 1 ? '' : 's') + ' ago'; + if (days < 7) + return 'Posted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago'; + if (weeks < 5) + return 'Posted ' + weeks + ' week' + (weeks === 1 ? '' : 's') + ' ago'; + return 'Posted ' + months + ' month' + (months === 1 ? '' : 's') + ' ago'; +} function sortJobsById(jobs) { return jobs.slice().sort(function (a, b) { @@ -20,58 +44,6 @@ function sortJobsById(jobs) { }); } -function renderJob(jobList, job, newBadgeUntil, now) { - var jobElement = document.createElement('div'); - jobElement.className = 'job-item'; - - var startInfo = job.posted_date || ''; - var postedAgo = job.posted_ago || ''; - var companyName = - typeof job.company === 'object' - ? (job.company && job.company.name) || 'Company' - : job.company || 'Company'; - - var isNew = - newBadgeUntil && newBadgeUntil[job.id] && newBadgeUntil[job.id] > now; - var badgeHtml = ''; - if (isNew) { - var profession = job.professionalTitle || 'Full-Stack Engineer'; - var badgeInfo = PROFESSION_BADGES[profession] || { - label: 'NEW', - color: '#555555', - }; - badgeHtml = - '' + - badgeInfo.label + - ' \u00B7 NEW'; - } - - jobElement.innerHTML = - '
' + - badgeHtml + - job.title + - '
' + - '
' + - companyName + - '
' + - '
' + - (job.location || 'Remote') + - '
' + - '
' + - startInfo + - (postedAgo ? ' - ' + postedAgo : '') + - '
'; - - jobElement.addEventListener('click', function () { - var url = job.url || 'https://app.andela.com/job-postings/' + job.id; - chrome.tabs.create({ url: url }); - }); - - jobList.appendChild(jobElement); -} - function sortWithNewFirst(jobs, newBadgeUntil, now) { return jobs.slice().sort(function (a, b) { var aNew = @@ -83,6 +55,55 @@ function sortWithNewFirst(jobs, newBadgeUntil, now) { }); } +function renderJob(jobList, job, newBadgeUntil, now) { + var jobElement = document.createElement('div'); + jobElement.className = 'job-item'; + + var startInfo = job.posted_date || ''; + var postedAgo = formatTimeAgo(job.posted_timestamp); + var companyName = + typeof job.company === 'object' + ? (job.company && job.company.name) || 'Company' + : job.company || 'Company'; + + var profession = job.professionalTitle || 'Full-Stack Engineer'; + var professionColor = + PROFESSION_COLORS[profession] || DEFAULT_PROFESSION_COLOR; + + var isNew = + newBadgeUntil && newBadgeUntil[job.id] && newBadgeUntil[job.id] > now; + var newBadgeHtml = isNew ? 'NEW' : ''; + + jobElement.innerHTML = + '
' + + newBadgeHtml + + job.title + + '
' + + '
' + + companyName + + '
' + + '
' + + (job.location || 'Remote') + + '
' + + '
' + + startInfo + + '' + + postedAgo + + '
' + + '
' + + profession + + '
'; + + jobElement.addEventListener('click', function () { + var url = job.url || 'https://app.andela.com/job-postings/' + job.id; + chrome.tabs.create({ url: url }); + }); + + jobList.appendChild(jobElement); +} + function updateUI(data) { var lastAndelaJobs = data.lastAndelaJobs || []; var lastAndelaUpdated = data.lastAndelaUpdated; @@ -106,9 +127,13 @@ function updateUI(data) { return; } - var opportunities = lastAndelaJobs.filter(function (j) { - return j.kind === 'opportunity'; - }); + var opportunities = sortWithNewFirst( + lastAndelaJobs.filter(function (j) { + return j.kind === 'opportunity'; + }), + newBadgeUntil, + now, + ); var applied = sortJobsById( lastAndelaJobs.filter(function (j) { return j.kind !== 'opportunity'; @@ -121,29 +146,8 @@ function updateUI(data) { oppHeading.textContent = 'Open Opportunities'; jobList.appendChild(oppHeading); - TITLE_ORDER.forEach(function (title) { - var group = sortWithNewFirst( - opportunities.filter(function (j) { - return (j.professionalTitle || 'Full-Stack Engineer') === title; - }), - newBadgeUntil, - now, - ); - if (!group.length) return; - - var badgeInfo = PROFESSION_BADGES[title] || { color: '#C9971C' }; - var titleHeading = document.createElement('h3'); - titleHeading.className = 'profession-heading'; - titleHeading.innerHTML = - '' + - title; - jobList.appendChild(titleHeading); - - group.slice(0, 10).forEach(function (job) { - renderJob(jobList, job, newBadgeUntil, now); - }); + opportunities.slice(0, 30).forEach(function (job) { + renderJob(jobList, job, newBadgeUntil, now); }); }