Removed all job category aggregation, display dates job posted and minor fixes
This commit is contained in:
@@ -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;
|
let keepAliveInterval = null;
|
||||||
|
|
||||||
function startKeepAlive() {
|
function startKeepAlive() {
|
||||||
@@ -455,21 +493,21 @@ function stopKeepAlive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merges freshly scraped jobs into stored jobs, notifies on new opportunities,
|
// Merges freshly scraped jobs into stored jobs, notifies on new opportunities,
|
||||||
// fetches posted dates, tracks a "new" badge window per job, and persists
|
// resolves posted-date timestamps, tracks a "new" badge window per job, and
|
||||||
// everything. Used by both check types.
|
// persists everything. Used by both check types.
|
||||||
async function processScrapedJobs(freshJobs) {
|
async function processScrapedJobs(freshJobs) {
|
||||||
const stored = await getStoredData([
|
const stored = await getStoredData([
|
||||||
'lastAndelaJobs',
|
'lastAndelaJobs',
|
||||||
'notifiedJobIds',
|
'notifiedJobIds',
|
||||||
'postedDates',
|
'postedTimestamps',
|
||||||
'backfillDone',
|
'backfillDone',
|
||||||
'newBadgeUntil',
|
'newBadgeUntil',
|
||||||
]);
|
]);
|
||||||
const notifiedJobIds = new Set(stored.notifiedJobIds || []);
|
const notifiedJobIds = new Set(stored.notifiedJobIds || []);
|
||||||
const postedDates = stored.postedDates || {};
|
const postedTimestamps = stored.postedTimestamps || {}; // { jobId: ms since epoch }
|
||||||
const backfillDone = stored.backfillDone || false;
|
const backfillDone = stored.backfillDone || false;
|
||||||
const previousJobs = stored.lastAndelaJobs || [];
|
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
|
// 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
|
// 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]));
|
const merged = dedupeJobs(sortJobsByRecency([...freshJobs, ...carriedOver]));
|
||||||
|
|
||||||
merged.forEach((job) => {
|
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) {
|
if (!backfillDone) {
|
||||||
const toBackfill = merged.filter(
|
const toBackfill = merged.filter(
|
||||||
(job) => job.kind === 'opportunity' && !postedDates[job.id],
|
(job) => job.kind === 'opportunity' && !postedTimestamps[job.id],
|
||||||
);
|
);
|
||||||
log(
|
log(
|
||||||
'Backfilling posted dates for ' + toBackfill.length + ' existing jobs...',
|
'Backfilling posted dates for ' + toBackfill.length + ' existing jobs...',
|
||||||
);
|
);
|
||||||
for (const job of toBackfill) {
|
for (const job of toBackfill) {
|
||||||
const postedDate = await fetchPostedDate(job.url);
|
const postedText = await fetchPostedDate(job.url);
|
||||||
if (postedDate) {
|
const timestamp = parsePostedAgoToTimestamp(postedText);
|
||||||
job.posted_ago = postedDate;
|
if (timestamp) {
|
||||||
postedDates[job.id] = postedDate;
|
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;
|
const badgeDurationMs = MULTI_TITLE_INTERVAL * 60 * 1000;
|
||||||
|
|
||||||
for (const job of newJobs) {
|
for (const job of newJobs) {
|
||||||
const postedDate = await fetchPostedDate(job.url);
|
const postedText = await fetchPostedDate(job.url);
|
||||||
if (postedDate) {
|
const timestamp = parsePostedAgoToTimestamp(postedText);
|
||||||
job.posted_ago = postedDate;
|
if (timestamp) {
|
||||||
postedDates[job.id] = postedDate;
|
job.posted_timestamp = timestamp;
|
||||||
|
postedTimestamps[job.id] = timestamp;
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
'Could not parse posted date for job ' +
|
||||||
|
job.id +
|
||||||
|
': "' +
|
||||||
|
postedText +
|
||||||
|
'"',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await notifyNewJob(job);
|
await notifyNewJob(job);
|
||||||
notifiedJobIds.add(job.id);
|
notifiedJobIds.add(job.id);
|
||||||
@@ -528,7 +585,7 @@ async function processScrapedJobs(freshJobs) {
|
|||||||
|
|
||||||
await setStoredData({
|
await setStoredData({
|
||||||
notifiedJobIds: Array.from(notifiedJobIds),
|
notifiedJobIds: Array.from(notifiedJobIds),
|
||||||
postedDates: postedDates,
|
postedTimestamps: postedTimestamps,
|
||||||
backfillDone: true,
|
backfillDone: true,
|
||||||
newBadgeUntil: newBadgeUntil,
|
newBadgeUntil: newBadgeUntil,
|
||||||
lastAndelaJobs: merged,
|
lastAndelaJobs: merged,
|
||||||
|
|||||||
@@ -29,21 +29,6 @@
|
|||||||
.section-heading-applied {
|
.section-heading-applied {
|
||||||
color: #c0392b;
|
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 {
|
.job-item {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -62,6 +47,7 @@
|
|||||||
.new-badge {
|
.new-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: white;
|
color: white;
|
||||||
|
background-color: #e03131;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -79,12 +65,21 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
.job-posted {
|
.job-posted-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
color: #0066cc;
|
color: #0066cc;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.profession-tag {
|
||||||
|
color: #c9971c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
.status {
|
.status {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -1,18 +1,42 @@
|
|||||||
// popup.js
|
// popup.js
|
||||||
|
|
||||||
var TITLE_ORDER = [
|
var PROFESSION_COLORS = {
|
||||||
'Full-Stack Engineer',
|
'Full-Stack Engineer': '#4C6EF5',
|
||||||
'AI Engineer',
|
'AI Engineer': '#9C36B5',
|
||||||
'Data Scientist',
|
'Data Scientist': '#0CA678',
|
||||||
'Front-End Engineer',
|
'Front-End Engineer': '#E8590C',
|
||||||
];
|
|
||||||
|
|
||||||
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 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) {
|
function sortJobsById(jobs) {
|
||||||
return jobs.slice().sort(function (a, b) {
|
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 =
|
|
||||||
'<span class="new-badge" style="background-color:' +
|
|
||||||
badgeInfo.color +
|
|
||||||
'">' +
|
|
||||||
badgeInfo.label +
|
|
||||||
' \u00B7 NEW</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
jobElement.innerHTML =
|
|
||||||
'<div class="job-title">' +
|
|
||||||
badgeHtml +
|
|
||||||
job.title +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="job-company">' +
|
|
||||||
companyName +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="job-location">' +
|
|
||||||
(job.location || 'Remote') +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="job-posted">' +
|
|
||||||
startInfo +
|
|
||||||
(postedAgo ? ' - ' + postedAgo : '') +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
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) {
|
function sortWithNewFirst(jobs, newBadgeUntil, now) {
|
||||||
return jobs.slice().sort(function (a, b) {
|
return jobs.slice().sort(function (a, b) {
|
||||||
var aNew =
|
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 ? '<span class="new-badge">NEW</span>' : '';
|
||||||
|
|
||||||
|
jobElement.innerHTML =
|
||||||
|
'<div class="job-title">' +
|
||||||
|
newBadgeHtml +
|
||||||
|
job.title +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="job-company">' +
|
||||||
|
companyName +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="job-location">' +
|
||||||
|
(job.location || 'Remote') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="job-posted-row"><span>' +
|
||||||
|
startInfo +
|
||||||
|
'</span><span>' +
|
||||||
|
postedAgo +
|
||||||
|
'</span></div>' +
|
||||||
|
'<div class="profession-tag" style="color:' +
|
||||||
|
professionColor +
|
||||||
|
'">' +
|
||||||
|
profession +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
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) {
|
function updateUI(data) {
|
||||||
var lastAndelaJobs = data.lastAndelaJobs || [];
|
var lastAndelaJobs = data.lastAndelaJobs || [];
|
||||||
var lastAndelaUpdated = data.lastAndelaUpdated;
|
var lastAndelaUpdated = data.lastAndelaUpdated;
|
||||||
@@ -106,9 +127,13 @@ function updateUI(data) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var opportunities = lastAndelaJobs.filter(function (j) {
|
var opportunities = sortWithNewFirst(
|
||||||
|
lastAndelaJobs.filter(function (j) {
|
||||||
return j.kind === 'opportunity';
|
return j.kind === 'opportunity';
|
||||||
});
|
}),
|
||||||
|
newBadgeUntil,
|
||||||
|
now,
|
||||||
|
);
|
||||||
var applied = sortJobsById(
|
var applied = sortJobsById(
|
||||||
lastAndelaJobs.filter(function (j) {
|
lastAndelaJobs.filter(function (j) {
|
||||||
return j.kind !== 'opportunity';
|
return j.kind !== 'opportunity';
|
||||||
@@ -121,30 +146,9 @@ function updateUI(data) {
|
|||||||
oppHeading.textContent = 'Open Opportunities';
|
oppHeading.textContent = 'Open Opportunities';
|
||||||
jobList.appendChild(oppHeading);
|
jobList.appendChild(oppHeading);
|
||||||
|
|
||||||
TITLE_ORDER.forEach(function (title) {
|
opportunities.slice(0, 30).forEach(function (job) {
|
||||||
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 =
|
|
||||||
'<span class="profession-dot" style="background-color:' +
|
|
||||||
badgeInfo.color +
|
|
||||||
'"></span>' +
|
|
||||||
title;
|
|
||||||
jobList.appendChild(titleHeading);
|
|
||||||
|
|
||||||
group.slice(0, 10).forEach(function (job) {
|
|
||||||
renderJob(jobList, job, newBadgeUntil, now);
|
renderJob(jobList, job, newBadgeUntil, now);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (applied.length) {
|
if (applied.length) {
|
||||||
|
|||||||
Reference in New Issue
Block a user