Skip to main content

Taxonomy & Filters

This page has one measurable goal: a user should reach a list of relevant jobs in fewer than 3 clicks. Opening a job from that list is one more click. We treat 3 clicks as a design budget, not a law — the point is that every click must feel like progress, not that a fourth click is a failure. A taxonomy is the fixed set of categories we use to group jobs. A filter is a control that narrows the list down to the jobs a user wants. For both of these to work, every category and every filter must pass two tests at the same time:

  • Findable — it matches a word that users actually search for. This evidence comes from the Vocabulary Lexicon.
  • Buildable — the data model can actually filter on it. This is confirmed by the backend audit.

A facet is one dimension that a user can browse or filter by, such as category, location, or employer. Some facets are findable but not yet buildable, which means users clearly want them but the backend cannot filter on them today. For those facets, we ship an interim version now, which is a keyword chip that runs a search. A chip is a small tappable button in the interface. At the same time, we write down the proper backend fix to build later. We do this because it improves discovery for users today, while the proper backend fix is built later.

The IA concepts behind this page

This page uses one established method, and it is honest about one popular myth.

  • Faceted navigation. Filters are "facets", which is an established way to let users narrow a large set from several independent angles at once (Ranganathan; Hearst, Search User Interfaces).
  • The "under 3 clicks" target is not a proven law. The "3-click rule" was tested by Joshua Porter (User Interface Engineering, 2003) and found to have no link to task success or satisfaction. We keep "under 3 clicks" only as a design budget. The real principle is information scent: each click must feel like progress toward the job the user wants.
  • Card sorting is the recommended way to confirm that our starter categories match how users group work. We have not run one yet; it is listed as a next step.

For the full list of concepts and sources, see Theory & Method Foundations.

The category taxonomy

The development seed for taxonomy_categories contains 89 categories. That is the canonical in-repo list; a production seed is not implemented, so the production count is unverified — confirm it before the re-seed. These 89 names have two problems.

The first problem is that they do not match demand. Most of the 89 names are office and professional roles, for example Software Engineering and Investment Banking. The words users actually search for — dishwasher, cashier, supermarket, packer — have no matching category. The second problem is that the two sides of the marketplace speak different category vocabularies. Every gig job passes through a fixed CATEGORY_MAP in the sync, and that map can only emit 7 values: Retail, F&B, Hotel, Healthcare, Logistics, Events, and the fallback Others. Two of those values — Logistics and Others — do not exist in the 89-name careers seed at all. In practice most gig jobs collapse into the "Others" bucket, which tells the user nothing useful.

For the browsing experience, we therefore re-seed the browse taxonomy as a small, demand-matched, extensible starter set: 8 categories chosen from search demand, plus an explicit Others catch-all. Each word in that phrase carries part of the decision:

  • Demand-matched — each starter category captures words users already search for (the evidence is in the table below).
  • Extensible — this is a starter set, not a ceiling. The CTO confirmed (2026-07-03) that the category set must not be limited to manual and service work: the business may extend to roles such as florists or admin staff. The written extension rule is below.
  • Starter set with a catch-all — jobs that fit no starter category go into Others, so every job always has a category and nothing is hidden.
The extension rule (written down so growth is boring)

A category earns its own browse entry when it reaches about 10 live jobs, or when Search Console shows clear search demand for it. Start with 10 and adjust the number with experience. Until a group of jobs reaches that bar, those jobs live under Others, and users still find them through full-text search. This rule is how a future "Florists" or "Admin & Office" growth happens without a redesign: seed the category, move the jobs, done.

Each user-facing category maps to existing backend data and captures proven demand words. The table below shows, for each category, the searched words it captures and the backend category it maps to:

User-facing categoryCaptures these searched wordsMaps to backend
Food & Beveragedishwasher (also called stewarding), kitchen, waiter, barista, pantry, catering assistant, service crewcareers Food & Beverage (in the dev seed); gig F&B
Supermarket & Retailcashier, supermarket, RA / retail assistant, trolleycareers Retail Sales; gig Retail
Warehouse & Logisticswarehouse, packer, food packing, driver, deliverycareers Transportation & Logistics; gig Logistics (missing from the careers seed)
Events & Promotionsevent, promoter, usher, roadshowcareers Events & Entertainment; gig Events
Hospitality & Hotelhotel, housekeeping, front deskcareers Hospitality; gig Hotel
Cleaningcleaner, cleaningnew (no gig value; gig cleaning jobs arrive as Hotel or Others)
Admin & Officedata entry, admin, part-time data entryexisting office categories in the careers seed
Healthcare & Carenursing, medical escort, carecareers Nursing; gig Healthcare
Others(catch-all — everything below the extension-rule bar)gig Others (the CATEGORY_MAP fallback; missing from the careers seed)
Evidence: Directional

Search Console, Singapore, 3 months:

  • The categories are chosen to match searched words (dishwasher, cashier, supermarket, and similar).
  • Some of those words are high-volume and some are small-sample, so treat the exact starter list as a starting point to confirm with card sorting.

One primary category per job (the tie-break rule)

Some search words could fit two categories: "promoter" (retail or events), "barista" (café or event booth), "stewarding" and "dishwasher" (the same work, two names), "housekeeping" and "cleaning", "food packing" (kitchen or warehouse). The fact: the data model stores one taxonomy_category_name per job, so one job cannot sit in two categories. The logic: browsing works when each word has exactly one home, and finding things across homes is the job of search — full-text search (search_vector) matches words in the title and description no matter which category the job is in. So the rule has two parts:

  1. Every job has exactly one primary category, and the employer chooses it when the job is posted.
  2. Every overlapping term is listed under exactly one primary category in the table above: promoter → Events & Promotions; barista → Food & Beverage; dishwasher and stewarding → Food & Beverage; housekeeping → Hospitality & Hotel; food packing → Warehouse & Logistics. The other meanings are treated as search synonyms. A user who searches "stewarding" still finds a hotel job whose employer chose Hospitality & Hotel, because search does not filter by category.
This re-seeds data in an existing table — it is not a new mechanism

Two clarifications, so this proposal is not confused with earlier decisions.

First, this proposal changes only the contents of the existing taxonomy_categories table (the seed data) and the values that flow into the existing taxonomy_category_name column on listings_jobs. It adds no table, no model, and no new filtering mechanism.

Second, a decision on 2026-05-07 (Gig pay rates and org job roles) rejected adding a new global taxonomy table (its "Option D", Org::JobRoleCategory). The reason given there was that marketplace filtering already works through search_vector and the denormalised taxonomy_category_name. This page does not conflict with that decision — it proposes exactly what that decision assumed: keep the existing mechanism, and curate the values that flow through it.

Categories with demand but unclear supply

Some categories have clear search demand but unclear supply. This means people search for them, but we are not yet sure we have jobs to match. These categories are:

  • cleaner
  • driver
  • promoter

Each of these gets impressions in search (it appears in the search results) but zero clicks. We still include them, so we can see whether we actually have jobs to match the demand. If we do not, that result tells us we need to recruit more employers for that kind of work. In other words, it flags a supply-recruitment need, not a design need.

The filter set, in priority order

The filters below are ranked by their value to the user and to the business, so priority 1 is the most useful filter. For each filter, the table lists its data source, whether we can build it now, and the interim option to use if we cannot build it yet.

PriorityFilterData sourceBuildable now?Interim if not
1Job type (chips: All · Gig · Part-time · Full-time)employment_type enum✅ Yes
2Category (the starter set above)taxonomy_category_name — already a NOT NULL column on listings_jobs, already serialized⚠️ Small backend job — see the work list belowKeyword chips that run a search
3Daily paynone — no payment-timing field exists anywhere⚠️ Open business decision — see below"Daily pay" keyword chip (text search only)
4Employer / Outletcompany_slug, outlet_slug✅ Yes — the filters already work in IndexManager
5Location (area)geo_area_path (ltree column, GIST index)✅ Yes — already works; see the caveats below
6Pay rangepay_from / pay_to✅ Yes
7High-intent chips (No experience · Training provided · Student-friendly · Weekend)careers_jobs booleans⚠️ Not projected to listings_jobsShow on careers cards only until projected
8Sort: Best match / Highest paysearch_vector rank; pay_from✅ "Best match" is a label change only; "Highest pay" is one new order_by value

Three terms in this table need a short definition. An enum is a fixed list of allowed values a field can take; for example, employment_type can be gig, part_time, full_time, contract, or casual. To project a field means to copy it onto another table (here listings_jobs) so the app can filter on it directly. A recency sort orders results newest-first.

One general rule for the whole table: every new filterable attribute must become a denormalised column on listings_jobs, populated in both projection builders — the careers sync (careers/jobs/sync_service.rb) and the gig sync (listings/jobs/gig_temp_job_attributes_service.rb) — plus a one-off backfill, because both sync paths are incremental and never rewrite old rows on their own.

The location filter needs special care

Three backend facts set the frame:

  1. The location filters already work. geo_area_path (an ltree column with a GIST index), company_slug, and outlet_slug are all filterable in IndexManager today.
  2. Every careers job carries the placeholder outlet hq. Careers outlets are not real data yet. So outlet-level filtering is meaningful only for gig jobs, until careers jobs get real outlets.
  3. No MRT data exists anywhere in the system. The geo hierarchy is the URA tree: country → region → planning area. Do not design an MRT filter; there is no data to power it.

The demand side is now strong evidence (see the Vocabulary Lexicon): employer-name queries beat specific-place queries by about 13 to 1 on impressions (13,612 vs 1,033); broad regions alone are 3 queries and 4 impressions; and "near me" (173 queries, 524 impressions) is how users express closeness, not planning-area names.

Because users start from the employer, and because the employer and outlet filters already work, the decision is: make Employer + Outlet the primary way a user narrows by location — gig jobs first, careers jobs once their outlets are real — and keep the region tree only as a coarse secondary filter.

The daily-pay badge and filter — one business rule decides the cost

"Daily pay" is our best-clicking generic search phrase (7.9% CTR against the 1.39% non-branded average), so the badge and filter matter. But the backend fact is blunt: no payment-timing field exists anywhere — not on listings_jobs, not in the JodGig sync payload, and not in the target gig schema (gig.dbml). So there are exactly two ways to build this:

  • Path A — adopt the business rule "every gig job pays the next day." Then "Daily pay" is just a frontend rendering of employment_type = gig. The badge and the filter need zero backend work.
  • Path B — no rule. Then payment timing must become data: a new column on listings_jobs, changes to both projection builders (careers/jobs/sync_service.rb and listings/jobs/gig_temp_job_attributes_service.rb), and a one-off backfill.

This is an open business decision. Owner: product. Our recommendation is Path A: the rule matches the current gig promise ("Work today, get paid tomorrow"), and it makes our strongest differentiator label free to build. If the business ever adds gig jobs that do not pay next-day, we fall back to Path B at that point.

Evidence: Strong
Checked in code (jodapp-api at main, JodGig sync payload, gig.dbml): no payment-timing field exists in any of the three places.

The interim chips (buildable this sprint, no backend change)

Until the category filter is built in the backend, we can still improve discovery today. We show chips that pre-fill the existing search with proven-demand words. This is genuinely low effort: the words render as static text next to a search handler that already works. It delivers value immediately, and the words on the chips match the language users already type. The keyword chips are:

  • Cashier
  • Dishwasher
  • Kitchen
  • F&B
  • Packer
  • Supermarket
  • Waiter
  • Barista
  • Daily pay
  • Weekend
  • NTUC
  • McDonald's

Two clarifications about this list:

  • The job-type chips (All · Gig · Part-time · Full-time) are not keyword chips. They are real filters on employment_type, which is buildable today, so they ship together with the keyword chips from day one. This is why there is no "Part-time" keyword chip: the real Part-time filter chip replaces it.
  • The Daily pay chip is an honest compromise. It runs a text search, so it only finds jobs whose title or description mentions daily pay. It is not a payment-timing filter. The real filter depends on the business decision above.

These chips live in two places: on the home hero, which is the large area at the top of the home page, and above the job list. Each chip is one click to a relevant list of jobs. They are a temporary solution, so we deliver value now while the proper filters are built later.

Under 3 clicks — worked examples

The target, stated precisely: reach a relevant list in fewer than 3 clicks. Opening a job from that list is one more click, and that is fine — 3 clicks is a design budget, not a law.

The first table uses only things we can ship now: keyword chips, the job-type chips, and the filters that already work.

GoalClick 1Click 2Result
"F&B job, paid fast"Keyword chip F&BType chip GigList of gig F&B jobs (all next-day pay, once the daily-pay rule is adopted)
"Cashier near me"Search cashierFilter Employer/OutletList at the chosen employer or outlet
"Weekend part-time"Type chip Part-timeKeyword chip WeekendList of weekend part-time jobs
"Jobs at NTUC"Nav CompaniesPick NTUC FairPriceList of NTUC jobs by outlet

The second table is the end-state version of the flagship example. It needs the category filter and the daily-pay decision, so it is not buildable in the interim:

GoalClick 1Click 2Result
"F&B job with daily pay"Category Food & BeverageFilter Daily payFiltered category list

Every example reaches a relevant list in 2 clicks, inside the budget. The click into a job itself is the third.

Backend work this taxonomy requires

The chips above are only the interim solution. The proper category filter is genuinely small, because the hard part already exists: taxonomy_category_name is a denormalised NOT NULL column on listings_jobs, and it is already serialized to the frontend. What remains, roughly in order:

  1. Re-seed the curated categories into taxonomy_categories (the starter set plus Others). Remember: the 89 names are the development seed; check what production actually holds before writing the migration.
  2. Reconcile CATEGORY_MAP with the curated set. Gig can only emit Retail, F&B, Hotel, Healthcare, Logistics, Events, and Others — and Logistics and Others are missing from the careers seed. If we ship the filter without this step, it shows two mismatched vocabularies side by side.
  3. Add a btree index on listings_jobs.taxonomy_category_name — none exists today, and the filter will be slow without it. Check the other filter columns (for example employment_type, is_visible) for missing indexes in the same pass.
  4. Add a category filter param — one param and one where-clause in Listings::Jobs::IndexRequest / IndexManager.
  5. Add a values endpoint that returns the current category list, so the filter UI has one source of truth instead of a hard-coded list.
  6. Run a one-off backfill. Both sync paths are incremental: the gig sync moves forward from a watermark, and the careers sync fires only when an employer creates or updates a job. Neither will rewrite existing rows with the new category values on its own.
  7. Decide the daily-pay rule (see the section above). Path A costs zero backend work; Path B costs a new column, both projection builders, and a backfill.
  8. Project the careers high-intent booleans (is_no_experience_required, is_training_provided, is_student_friendly, is_weekend_friendly) onto listings_jobs, following the general rule: denormalised column, both projection builders, one-off backfill.
  9. Expose the two missing sorts. There is no ordering bug to fix: when a search term is present, pg_search already puts relevance first — rank DESC is the primary ORDER BY inside full_text_search, and the chained created_at DESC never takes effect while a query is present. The real gap is the UI, which today offers exactly two sorts: "Latest Created" and "Latest Updated". So: (a) show a "Best match" sort label and make it the default whenever a query is present — a label change, no backend work; and (b) add a "Highest pay" sort — a small backend change, one more allowed order_by value.
Evidence: Strong

The backend facts in this list were checked in code (jodapp-api at main):

  • The denormalised taxonomy_category_name column and its serialization.
  • The missing index.
  • The CATEGORY_MAP values.
  • The incremental behaviour of both sync paths.
  • The pg_search ordering.
Sequencing

Do the cheap, high-value work first. Ship the keyword chips and the job-type chips (zero backend change), together with the employer, location, and pay filters and the "Best match" label, which are already buildable. These cover most of the demand immediately. The category filter follows once steps 1–6 above are done, and the daily-pay badge follows the business decision in step 7. Doing the work in this order lets discovery improve in the first week, without waiting for the backend work to finish.