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
- Bumped colour stops. The lowest non-zero band now
- Hover tooltip. A MapLibre Popup pinned to the cursor
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:
- Backend —
getPortalStats() + @Get('portal-stats')
public route, deployed to asistan-api.
- Web —
WorldChoropleth 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.