Skip to main content
All insights

2026-05-13DataMesh Consulting

13 May — world-map heatmap is real now, portal-stats endpoint ships, two stealth UX bugs caught

The world map on the homepage and country pages had been a flat dark base layer with no per-country shading for weeks. We traced it to a missing backend endpoint, built it, then upgraded the choropleth itself with log-scale intensity, a hover tooltip, and a colour-ramp legend. Also caught and fixed two stealth UX bugs along the way — CPV codes that read as tender counts, and an empty sector grid on countries with zero corpus.

The flat map

The world map shown on the homepage and on every /countries/<code> detail page is rendered by a MapLibre choropleth — country polygons coloured by the number of active tenders we track per country. Lighter blue for small markets, saturated purple-pink for the top tier.

Except it wasn't. Looking at the live map in production, every country rendered as the same uniform dark grey. No heat differentiation. No tier of "EU and GB look big, the Nordics moderate, South Africa medium, Türkiye empty."

Why

Traced through the stack:

1. The web's getPortalStats() helper hits GET /v1/dashboard/portal-stats to fetch { totalActiveTenders, topCountries, topSectors, ... }. 2. That endpoint never existed on the backend. It returned 404 in prod. 3. The web client has a defensive try/catch — when the API call fails, it returns an empty stats object with topCountries: []. 4. The WorldChoropleth component takes countries: Array<{ country, count }> as props. With an empty array, every country's intensity computes to 0, and the heatmap fill layer renders fully transparent. 5. End result: just the dark base raster, no overlay.

The map had been working once, in development. Somewhere between then and the multi-schema split (which moved tenders and tender_sites from public. to app.), the endpoint we'd been hitting got dropped from the route table. No one noticed because the page didn't break — it just silently lost a feature.

The fix — backend

Added a DashboardService.getPortalStats() method with two SQL aggregates:

``sql -- topCountries: join tenders to tender_sites for the country SELECT s."country" AS country, COUNT(t.id)::bigint AS count FROM "app"."tenders" t JOIN "app"."tender_sites" s ON s.id = t."siteId" WHERE t.status = 'ACTIVE' AND s."country" IS NOT NULL GROUP BY s."country" ORDER BY count DESC LIMIT 80;

-- topSectors: UNNEST the cpvCodes[] array, group by 2-digit prefix SELECT LEFT(UNNEST("cpvCodes"), 2) AS prefix, COUNT(*)::bigint AS count FROM "app"."tenders" WHERE status = 'ACTIVE' AND array_length("cpvCodes", 1) > 0 GROUP BY prefix HAVING LEFT(UNNEST("cpvCodes"), 2) ~ '^[0-9]{2}$' ORDER BY count DESC LIMIT 20; `

Result cached for 5 min in Redis — short enough to reflect new-tender bumps within the user's coffee, long enough to absorb 95% of homepage hits without re-running the GROUP BY.

The route lives at GET /v1/dashboard/portal-stats with @Public() @SkipThrottle() — bypasses the class-level JwtAuthGuard + AdminGuard via the IS_PUBLIC_KEY metadata pattern, and the per-IP throttle (100 req / 60s) doesn't apply because this is a homepage hot path.

The fix — frontend visibility

Even with real data flowing, the choropleth had a separate visibility problem: a heavily-skewed corpus (EU + GB at ~90% of all tenders, long tail < 50 each) means a linear intensity formula leaves most countries at near-zero intensity. The top two saturate the scale; everyone else disappears.

Three changes to WorldChoropleth.tsx:

  • Log-scale intensity. log1p(count) / log1p(max) so a
country with 1 tender registers at ~0.05 visible intensity (was effectively 0). Top stops still saturate normally.
  • Bumped colour stops. The lowest non-zero band now
starts at 30% opacity (was 18%). Top stop pushes to 90% opacity for a strong "this is a big market" signal. Five stops total for a smoother transition.
  • Hover tooltip. A MapLibre Popup pinned to the cursor
shows
<Country name> · N active tenders for countries with data. Empty countries get no tooltip — we don't want to promise something we can't deliver.
  • Legend. Bottom-left translucent panel with the colour-
ramp gradient bar (fewer ←→ more) plus the top-5 countries as count pills. Hidden in country-focus mode where a global legend would just clutter.

Two stealth UX bugs caught along the way

The user pointed out two things while we were in this code:

1 · CPV codes read as tender counts

The country hub and homepage sector cloud showed sector cards like this:

` Mining, basic metals & related 14 Food, beverages & tobacco 15 Agricultural machinery 16 Clothing & footwear 18 `

Without a count column for context, every visitor read those numbers as tender counts. They aren't — they're the EU's CPV-2-digit classification identifiers (14 = Mining, 15 = Food, etc). On the Türkiye page especially, this combined with 0 actual active Turkish tenders to make the page look like it was advertising data we don't have.

Hidden the visible prefix. The CPV code stays in the URL (/sectors/14, /tenders/tr/14) for routing accuracy, just no longer surfaced as a misleading numeric badge.

2 · Sector grid on empty-corpus countries

Same pages rendered the "<Country> tenders by sector" grid regardless of whether the country had any tenders at all. Türkiye (0 active), Algeria, a handful of others — all got a hopeful-looking 8-card grid that linked into completely empty programmatic pages.

Now gated: the sector grid only renders when visibleCount > 0. Empty countries show the intro paragraph + FAQ only — honest representation of the current state.

A non-fix worth mentioning

The Türkiye page implies we "monitor the official public- procurement portals for Türkiye" — but we have zero Turkish tenders in the corpus because no Turkish portal scraper exists yet. The page promises something we're not delivering.

Three options:

1. Spin up an EKAP / Kamu İhale Kurumu scraper — proper fix, multi-hour job. 2. Tone down the copy when a country has zero coverage — "we don't track Türkiye yet, but we will" — cheap, honest. 3. Hide the country entirely until we have a scraper — harsh, but cleanest.

Going with #2 in a follow-up sprint. Building the scraper is the right answer long-term but it requires a working Hermes agent pointed at EKAP's specific HTML structure plus auth handling for the Turkish portals that require it.

Status & what's next

The heatmap fix shipped tonight in two commits:

  • BackendgetPortalStats() + @Get('portal-stats')
public route, deployed to asistan-api.
  • WebWorldChoropleth log scale + tooltip + legend,
CPV-as-counts hidden, sector grid gated, deployed to asistan-web.

Next time you visit the homepage or /countries`, the map should actually look like a heatmap. Hover any country to see its name + tender count. The legend explains the colour scale. The top-5 markets are pinned as count pills so you can immediately see "GB is leading, EU close behind, ..." without hunting for the bright spots.

Methodology: drawn from the week ending 2026-05-13 tender corpus. Tender data sourced from public procurement portals worldwide; see our methodology for the extraction pipeline.
13 May — world-map heatmap is real now, portal-stats endpoint ships, two stealth UX bugs caught