diff --git a/api/upwork.ts b/api/upwork.ts index 90c62ce..1c12250 100644 --- a/api/upwork.ts +++ b/api/upwork.ts @@ -48,6 +48,9 @@ export type Job = { paymentVerificationStatus: number | null totalFeedback: number totalSpent: number + // Returned by all feeds but only surfaced for filtering (optional per feed). + totalReviews?: number + totalHires?: number location?: { country: string | null @@ -65,6 +68,10 @@ export type Job = { renewedOn: Date | null createdOn: Date | string + // Surfaced for filtering (present on all feeds via the raw response). + isApplied?: boolean + premium?: boolean + // internal attribute __isSeen: boolean } diff --git a/components/StorageProvider.tsx b/components/StorageProvider.tsx index 6d8e324..4da6c00 100644 --- a/components/StorageProvider.tsx +++ b/components/StorageProvider.tsx @@ -3,6 +3,7 @@ import jobStorage from '@/utils/jobs' import promptStorage from '@/utils/prompt' import { GlobalState } from '@/utils/globalState' import globalState from '@/utils/globalState' +import jobFiltersStorage, { JobFiltersConfig } from '@/utils/jobFilters' import { ReactNode, useEffect, useState } from 'react' import Storage, { StorageInterface } from '@/contexts/storage' type Props = { @@ -14,33 +15,44 @@ const StorageProvider = (props: Props) => { const [prompt, setPrompt] = useState('') const [initialized, setInitialized] = useState(false) const [state, setState] = useState(globalState.getDefaultState) + const [jobFilters, setJobFilters] = useState( + jobFiltersStorage.getDefaultConfig + ) const exposedApi: StorageInterface = { jobs, prompt, initialized, globalState: state, + jobFilters, setJobs: jobStorage.save, setState: globalState.save, setPrompt: promptStorage.save, + setJobFilters: jobFiltersStorage.save, } useEffect(() => { const initializeStorage = async () => { - const [freshState, freshJobs, freshPrompt] = await Promise.all([ - globalState.get(), - jobStorage.getAll(), - promptStorage.get(), - ]) + const [freshState, freshJobs, freshPrompt, freshFilters] = + await Promise.all([ + globalState.get(), + jobStorage.getAll(), + promptStorage.get(), + jobFiltersStorage.get(), + ]) setJobs(freshJobs ?? []) setState(freshState) setPrompt(freshPrompt) + setJobFilters(freshFilters) jobStorage.addEventListener((newJobs) => setJobs(newJobs ?? [])) globalState.addEventListener((newState) => setState(newState ?? globalState.getDefaultState()) ) + jobFiltersStorage.addEventListener((newFilters) => + setJobFilters(newFilters ?? jobFiltersStorage.getDefaultConfig()) + ) setInitialized(true) } diff --git a/contexts/storage.ts b/contexts/storage.ts index a03ed75..9d193b5 100644 --- a/contexts/storage.ts +++ b/contexts/storage.ts @@ -3,16 +3,19 @@ import { createContext } from 'react' import jobStorage from '@/utils/jobs' import promptStorage from '@/utils/prompt' import globalState, { GlobalState } from '@/utils/globalState' +import jobFiltersStorage, { JobFiltersConfig } from '@/utils/jobFilters' export type StorageInterface = { initialized: boolean jobs: Job[] prompt: string globalState: GlobalState + jobFilters: JobFiltersConfig setJobs: typeof jobStorage.save setState: typeof globalState.save setPrompt: typeof promptStorage.save + setJobFilters: typeof jobFiltersStorage.save } const StorageContext = createContext({ @@ -20,10 +23,12 @@ const StorageContext = createContext({ jobs: [], prompt: '', globalState: globalState.getDefaultState(), + jobFilters: jobFiltersStorage.getDefaultConfig(), setJobs: Promise.resolve, setState: Promise.resolve, setPrompt: Promise.resolve, + setJobFilters: Promise.resolve, }) export default StorageContext diff --git a/entrypoints/background/fetchJobs.ts b/entrypoints/background/fetchJobs.ts index 05f5d20..a71747c 100644 --- a/entrypoints/background/fetchJobs.ts +++ b/entrypoints/background/fetchJobs.ts @@ -5,6 +5,8 @@ import { ErrorType } from '@/utils/errors' import extension from '@/utils/extension' import stateStorage from '@/utils/globalState' import jobStorage from '@/utils/jobs' +import jobFiltersStorage from '@/utils/jobFilters' +import { filterJobs } from '@/utils/matchJob' import logger from '@/utils/logger' import notifications from '@/utils/notifications' import { captureEvent, captureException } from '@/utils/sentry' @@ -104,19 +106,23 @@ const fetchJobs = async () => { (job) => job.ciphertext ) + const newJobs = newBatch.filter( + (job) => !oldBatchIds.includes(job.ciphertext) + ) + const newProcessedBatch = [ - ...newBatch - .filter((job) => !oldBatchIds.includes(job.ciphertext)) - .map((job) => ({ ...job, __isSeen: false })), + ...newJobs.map((job) => ({ ...job, __isSeen: false })), ...(oldBatch ?? []), ].slice(0, 50) + // Advanced filters refine what notifies and what's counted; every job is + // still stored so the list can be un-filtered without re-fetching. + const filters = await jobFiltersStorage.get() + const unseenJobs = newProcessedBatch.filter((job) => !job.__isSeen) - const unseenCount = unseenJobs.length + const unseenCount = filterJobs(unseenJobs, filters).length - const hasNewUnseenJobs = - unseenJobs.length > 0 && - unseenJobs.some((job) => !oldBatchIds.includes(job.ciphertext)) + const hasNewUnseenJobs = filterJobs(newJobs, filters).length > 0 await Promise.all([ jobStorage.save(newProcessedBatch), diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 7ec0854..c5a3399 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -8,6 +8,7 @@ import { SnackbarProvider } from 'notistack' import analytics from '@/utils/analytics' import Settings from './pages/Settings' import CoverLetter from './pages/CoverLetter' +import Filters from './pages/Filters' import { useContext, useEffect, useState } from 'react' import { LocalizationProvider } from '@mui/x-date-pickers' import { Route, Routes, HashRouter } from 'react-router-dom' @@ -50,6 +51,7 @@ const App = () => { } /> } /> } /> + } /> diff --git a/entrypoints/options/pages/Filters.tsx b/entrypoints/options/pages/Filters.tsx new file mode 100644 index 0000000..a0497bc --- /dev/null +++ b/entrypoints/options/pages/Filters.tsx @@ -0,0 +1,458 @@ +import Storage, { StorageInterface } from '@/contexts/storage' +import useMediaQuery from '@/hooks/useMediaQuery' +import errors from '@/utils/errors' +import jobFiltersStorage, { + BudgetType, + EXPERIENCE_LEVELS, + ExperienceLevel, + JobFiltersConfig, +} from '@/utils/jobFilters' +import { filterJobs } from '@/utils/matchJob' +import { captureException } from '@/utils/sentry' +import { Save } from '@mui/icons-material' +import { + Alert, + AlertTitle, + Autocomplete, + Box, + Button, + Checkbox, + Divider, + FormControlLabel, + FormGroup, + InputAdornment, + MenuItem, + Paper, + Slider, + Switch, + TextField, + Typography, +} from '@mui/material' +import { useSnackbar } from 'notistack' +import { useContext, useState } from 'react' + +const ChipInput = (props: { + label: string + placeholder?: string + value: string[] + disabled?: boolean + onChange: (value: string[]) => void +}) => ( + props.onChange(value)} + renderInput={(params) => ( + + )} + /> +) + +const NumberField = (props: { + label: string + value: number | null + disabled?: boolean + adornment?: string + placeholder?: string + onChange: (value: number | null) => void +}) => ( + { + const raw = e.target.value + const parsed = Number(raw) + props.onChange(raw === '' || Number.isNaN(parsed) ? null : parsed) + }} + slotProps={ + props.adornment + ? { + input: { + startAdornment: ( + + {props.adornment} + + ), + }, + } + : undefined + } + /> +) + +const Row = (props: { children: React.ReactNode }) => ( + + {props.children} + +) + +const Filters = () => { + const { isMobile } = useMediaQuery() + const { enqueueSnackbar } = useSnackbar() + const storage = useContext(Storage) + + const [config, setConfig] = useState(storage.jobFilters) + const [saving, setSaving] = useState(false) + + const update = (patch: Partial) => + setConfig((prev) => ({ ...prev, ...patch })) + + const toggleExperience = (level: ExperienceLevel) => + update({ + experienceLevels: config.experienceLevels.includes(level) + ? config.experienceLevels.filter((value) => value !== level) + : [...config.experienceLevels, level], + }) + + // Live preview against the currently stored jobs, ignoring the enable toggle. + const total = storage.jobs.length + const matching = filterJobs(storage.jobs, { ...config, enabled: true }).length + + const onSave = async () => { + setSaving(true) + try { + await jobFiltersStorage.save(config) + enqueueSnackbar('Your filters have been saved', { variant: 'success' }) + } catch (error) { + captureException(error) + enqueueSnackbar(errors.getErrorMessage(error), { variant: 'error' }) + } finally { + setSaving(false) + } + } + + return ( + + + Filter your job feed + + + + How this works + These filters refine the jobs your feed already returns — hiding + non-matching jobs from the list and stopping them from notifying you. + Because the extension has no server, it can only filter jobs Upwork puts + in your selected feed; it can't search all of Upwork. For a wider net, + tune your Saved Search on Upwork itself. + + + + update({ enabled: e.target.checked })} + /> + } + label="Enable filters" + /> + update({ strictMode: e.target.checked })} + /> + } + label="Strict mode" + /> + + + + Strict mode drops jobs that are missing data for a filter you set (e.g. + a client with no rating). Off by default (missing data passes). + + + + {matching} of your last {total} jobs + match these filters. + + + {/* Keywords */} + + Keywords + + Matched against the job title and description. + + + + update({ includeKeywords: value })} + /> + + + + update({ excludeKeywords: value })} + /> + + + + {/* Skills */} + + Skills + + Matches the job's skill tags. Use the exact Upwork skill name (e.g. + "React", not "reactjs"). + + + + update({ requireSkills: value })} + /> + + + + update({ excludeSkills: value })} + /> + + + + {/* Budget */} + + Budget + + + + update({ budgetType: e.target.value as BudgetType }) + } + > + Both fixed & hourly + Fixed-price only + Hourly only + + + + {config.budgetType !== 'fixed' && ( + + + update({ minHourly: value })} + /> + + + update({ maxHourly: value })} + /> + + + )} + + {config.budgetType !== 'hourly' && ( + + + update({ minFixed: value })} + /> + + + update({ maxFixed: value })} + /> + + + )} + + + {/* Job */} + + Job + + + Experience level (any if none selected) + + + {EXPERIENCE_LEVELS.map((level) => ( + toggleExperience(level)} + /> + } + label={level} + /> + ))} + + + update({ repeatClientsOnly: e.target.checked })} + /> + } + label="Only clients I've worked with before" + /> + update({ excludeApplied: e.target.checked })} + /> + } + label="Hide jobs I've already applied to" + /> + + + {/* Client */} + + Client + + + Minimum client rating: {config.minRating ?? 'any'} + + + update({ + minRating: (value as number) === 0 ? null : (value as number), + }) + } + /> + + + + update({ minReviews: value })} + /> + + + update({ minHires: value })} + /> + + + + + + update({ minSpent: value })} + /> + + + update({ maxSpent: value })} + /> + + + + + update({ excludeCountries: value })} + /> + + + + update({ paymentVerifiedOnly: e.target.checked }) + } + /> + } + label="Payment-method verified clients only" + /> + + + + + + + + + ) +} + +export default Filters diff --git a/entrypoints/options/pages/Home.tsx b/entrypoints/options/pages/Home.tsx index e57a6e7..8ad0048 100644 --- a/entrypoints/options/pages/Home.tsx +++ b/entrypoints/options/pages/Home.tsx @@ -8,6 +8,7 @@ import colors from '@/utils/colors' import { ErrorType } from '@/utils/errors' import extension from '@/utils/extension' import notifications from '@/utils/notifications' +import { filterJobs } from '@/utils/matchJob' import { AlertTitle, Box, @@ -45,6 +46,12 @@ const Home = () => { .join('/') : '' + // Jobs remain stored in full; filters only affect what's shown and counted. + const visibleJobs = filterJobs(storage.jobs, storage.jobFilters) + const visibleUnseenIds = unseenIds.filter((id) => + visibleJobs.some((job) => job.ciphertext === id) + ) + const lastCycleError = storage.globalState.lastCycleError const loginAttempted = @@ -285,12 +292,13 @@ const Home = () => { ) useEffect(() => { - eventEmitter.emit(Event.UNSEEN_IDS_UPDATED, unseenIds) + eventEmitter.emit(Event.UNSEEN_IDS_UPDATED, visibleUnseenIds) return () => { eventEmitter.emit(Event.UNSEEN_IDS_UPDATED, []) } - }, [unseenIds]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visibleUnseenIds.join('/')]) return ( <> @@ -342,9 +350,18 @@ const Home = () => { )} + {storage.jobs.length > 0 && visibleJobs.length === 0 && ( + + No jobs match your filters + None of your {storage.jobs.length} stored jobs pass your active + filters. Adjust them on the Filters{' '} + page. + + )} + diff --git a/entrypoints/options/pages/Layout.tsx b/entrypoints/options/pages/Layout.tsx index dfd32a5..4110035 100644 --- a/entrypoints/options/pages/Layout.tsx +++ b/entrypoints/options/pages/Layout.tsx @@ -4,6 +4,7 @@ import Storage, { StorageInterface } from '@/contexts/storage' import useMediaQuery from '@/hooks/useMediaQuery' import { Event, eventEmitter } from '@/utils/events' import extension from '@/utils/extension' +import { filterJobs } from '@/utils/matchJob' import { AppBar, Box, @@ -47,7 +48,9 @@ const Layout = () => { const [debugMode, setDebugMode] = useState(false) const [unseenIds, setUnseenIds] = useState([]) - const unseenJobs = storage.jobs.filter((job) => !job.__isSeen) + const unseenJobs = filterJobs(storage.jobs, storage.jobFilters).filter( + (job) => !job.__isSeen + ) const socialLinks = [ { href: import.meta.env.WXT_GITHUB_URL, label: 'GitHub', Icon: GithubIcon }, @@ -57,6 +60,7 @@ const Layout = () => { const menuLinks: MenuLink[] = [ { text: 'Jobs', href: '', count: unseenIds.length || unseenJobs.length }, { text: 'Cover letter', href: 'cover-letter', new: true }, + { text: 'Filters', href: 'filters', new: true }, { text: 'Settings', href: 'settings' }, ...(import.meta.env.DEV || debugMode ? [ diff --git a/utils/jobFilters.ts b/utils/jobFilters.ts new file mode 100644 index 0000000..e03a921 --- /dev/null +++ b/utils/jobFilters.ts @@ -0,0 +1,100 @@ +import { storage } from '#imports' +import isFunction from 'lodash/isFunction' + +const namespace = 'sync:__JOB_FILTERS' + +export type BudgetType = 'any' | 'fixed' | 'hourly' + +export const EXPERIENCE_LEVELS = ['Entry', 'Intermediate', 'Expert'] as const +export type ExperienceLevel = (typeof EXPERIENCE_LEVELS)[number] + +/** + * A single, global set of advanced filters applied to the fetched Upwork feed. + * Persisted in cloud storage (synced across the user's Chrome). + */ +export type JobFiltersConfig = { + enabled: boolean + // When a job is missing data for an active filter: strict → exclude it, + // lenient (default) → let it through. + strictMode: boolean + + includeKeywords: string[] // job must contain ANY of these (title + description) + excludeKeywords: string[] // reject if ANY of these appears + + requireSkills: string[] // job skills must include ≥1 of these + excludeSkills: string[] // reject if job has any of these skills + + budgetType: BudgetType + minHourly: number | null + maxHourly: number | null + minFixed: number | null + maxFixed: number | null + + experienceLevels: ExperienceLevel[] // empty = any + + minRating: number | null // client rating (0–5) + minReviews: number | null + minSpent: number | null + maxSpent: number | null + minHires: number | null + paymentVerifiedOnly: boolean + excludeCountries: string[] // reject clients from these countries + repeatClientsOnly: boolean // only jobs from clients you've worked with + + excludeApplied: boolean // hide jobs you've already applied to +} + +export const getDefaultConfig = (): JobFiltersConfig => ({ + enabled: false, + strictMode: false, + + includeKeywords: [], + excludeKeywords: [], + + requireSkills: [], + excludeSkills: [], + + budgetType: 'any', + minHourly: null, + maxHourly: null, + minFixed: null, + maxFixed: null, + + experienceLevels: [], + + minRating: null, + minReviews: null, + minSpent: null, + maxSpent: null, + minHires: null, + paymentVerifiedOnly: false, + excludeCountries: [], + repeatClientsOnly: false, + + excludeApplied: false, +}) + +const get = () => + storage.getItem(namespace, { fallback: getDefaultConfig() }) + +const save = async ( + arg: + Partial | ((prev: JobFiltersConfig) => JobFiltersConfig) +): Promise => { + // Shallow merge: array fields are replaced wholesale (not merged by index). + const updated = isFunction(arg) + ? arg(await get()) + : { ...(await get()), ...arg } + + await storage.setItem(namespace, updated) + return updated +} + +const addEventListener = ( + callback: ( + newValue: JobFiltersConfig | null, + oldValue: JobFiltersConfig | null + ) => void +) => storage.watch(namespace, callback) + +export default { get, save, addEventListener, getDefaultConfig } diff --git a/utils/matchJob.ts b/utils/matchJob.ts new file mode 100644 index 0000000..dd244aa --- /dev/null +++ b/utils/matchJob.ts @@ -0,0 +1,225 @@ +import { Job } from '@/api/upwork' +import { ExperienceLevel, JobFiltersConfig } from '@/utils/jobFilters' + +/** Plain, lowercased text of a job for keyword matching (strips Upwork HTML). */ +const jobText = (job: Job) => + `${job.title} ${job.description}`.replace(/<[^>]*>/g, ' ').toLowerCase() + +const jobSkills = (job: Job) => + (job.attrs ?? []) + .map((attr) => attr.prettyName?.toLowerCase().trim()) + .filter((name): name is string => Boolean(name)) + +/** Normalize the various tier strings into Entry / Intermediate / Expert. */ +const normalizeTier = (tier: string | null): ExperienceLevel | null => { + if (!tier) return null + const value = tier.toLowerCase() + if (value.includes('entry')) return 'Entry' + if (value.includes('inter')) return 'Intermediate' + if (value.includes('expert')) return 'Expert' + return null +} + +const clean = (values: string[]) => + values.map((value) => value.toLowerCase().trim()).filter(Boolean) + +/** + * A rule that depends on a possibly-missing job field. If the filter is + * inactive it passes; if the field is missing, strict mode excludes it, + * otherwise it passes. + */ +const check = ( + active: boolean, + value: number | null | undefined, + test: (value: number) => boolean, + strict: boolean +): boolean => { + if (!active) return true + if (value == null) return !strict + return test(value) +} + +export const matchesFilters = (job: Job, config: JobFiltersConfig): boolean => { + const strict = config.strictMode + const text = jobText(job) + + // ── Keywords ──────────────────────────────────────────────────────────── + const include = clean(config.includeKeywords) + if (include.length && !include.some((word) => text.includes(word))) { + return false + } + + const exclude = clean(config.excludeKeywords) + if (exclude.length && exclude.some((word) => text.includes(word))) { + return false + } + + // ── Skills ────────────────────────────────────────────────────────────── + const skills = jobSkills(job) + + const requireSkills = clean(config.requireSkills) + if (requireSkills.length) { + if (!skills.length) { + if (strict) return false + } else if (!requireSkills.some((skill) => skills.includes(skill))) { + return false + } + } + + const excludeSkills = clean(config.excludeSkills) + if ( + excludeSkills.length && + excludeSkills.some((skill) => skills.includes(skill)) + ) { + return false + } + + // ── Budget ────────────────────────────────────────────────────────────── + if (config.budgetType === 'fixed' && job.type !== 'Fixed-price') return false + if (config.budgetType === 'hourly' && job.type !== 'Hourly') return false + + if (job.type === 'Hourly') { + const top = job.hourlyBudget?.max || job.hourlyBudget?.min || null + const bottom = job.hourlyBudget?.min || null + // Job's top rate must clear the minimum; its bottom rate must be under the max. + if ( + !check( + config.minHourly != null, + top, + (v) => v >= config.minHourly!, + strict + ) + ) { + return false + } + if ( + !check( + config.maxHourly != null, + bottom, + (v) => v <= config.maxHourly!, + strict + ) + ) { + return false + } + } + + if (job.type === 'Fixed-price') { + const amount = Number(job.amount?.amount) + const budget = Number.isFinite(amount) && amount > 0 ? amount : null + if ( + !check( + config.minFixed != null, + budget, + (v) => v >= config.minFixed!, + strict + ) + ) { + return false + } + if ( + !check( + config.maxFixed != null, + budget, + (v) => v <= config.maxFixed!, + strict + ) + ) { + return false + } + } + + // ── Experience ────────────────────────────────────────────────────────── + if (config.experienceLevels.length) { + const tier = normalizeTier(job.tierText) + if (!tier) { + if (strict) return false + } else if (!config.experienceLevels.includes(tier)) { + return false + } + } + + // ── Client ────────────────────────────────────────────────────────────── + if ( + !check( + config.minRating != null, + job.client?.totalFeedback, + (v) => v >= config.minRating!, + strict + ) + ) { + return false + } + if ( + !check( + config.minReviews != null, + job.client?.totalReviews, + (v) => v >= config.minReviews!, + strict + ) + ) { + return false + } + if ( + !check( + config.minSpent != null, + job.client?.totalSpent, + (v) => v >= config.minSpent!, + strict + ) + ) { + return false + } + if ( + !check( + config.maxSpent != null, + job.client?.totalSpent, + (v) => v <= config.maxSpent!, + strict + ) + ) { + return false + } + if ( + !check( + config.minHires != null, + job.client?.totalHires, + (v) => v >= config.minHires!, + strict + ) + ) { + return false + } + + if (config.paymentVerifiedOnly) { + const status = job.client?.paymentVerificationStatus + if (status == null) { + if (strict) return false + } else if (status !== 1) { + return false + } + } + + const excludeCountries = clean(config.excludeCountries) + if (excludeCountries.length) { + const country = job.client?.location?.country?.toLowerCase().trim() + if (!country) { + if (strict) return false + } else if (excludeCountries.includes(country)) { + return false + } + } + + if (config.repeatClientsOnly && !job.clientRelation) return false + + if (config.excludeApplied && job.isApplied) return false + + return true +} + +/** Filter a list of jobs when filtering is enabled; otherwise return as-is. */ +export const filterJobs = ( + jobs: T[], + config: JobFiltersConfig | null | undefined +): T[] => + config?.enabled ? jobs.filter((job) => matchesFilters(job, config)) : jobs