Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Data-driven page at `/clients-feedback/`. Key dirs: `src/data/clients-feedback/`

Data-driven page at `/partners/distributors/`. Import distributor data from `@data/partners` — it exports the derived selectors the page renders from (`OFFERED_COUNTRIES`, `REGION_OFFERED_COUNTRIES`, `getCoverage`). Distributor-scoped: hardware partners live in `@data/partners/hardware-partners` and are imported directly.

A distributor either lists the countries it covers or sets `countries: 'region-wide'` to cover every country in its `regions`, expanded from `REGION_MEMBERSHIP` in `src/data/partners/regions.ts`. That table and the countries distributors name must stay in step, so adding a country to a distributor means classifying it there too. `distributors.ts` asserts this as it loads (via `coverage.ts`), so any import path — the barrel or the data file directly — fails the build until you do.
A distributor either lists the countries it covers or sets `countries: 'region-wide'` to cover every country in its `regions`, expanded from `REGION_MEMBERSHIP` in `src/data/partners/regions.ts`. That table and the countries distributors name must stay in step, so adding a country to a distributor means classifying it there too — and declaring every region the country falls under, because the finder only offers a region's own countries in its dropdown and a card only matches regions it declares. `distributors.ts` asserts both as it loads (via `coverage.ts`), so any import path — the barrel or the data file directly — fails the build until you do.

## Redirects

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/case-studies/cuba-iot-platform.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 14 additions & 2 deletions src/components/CaseStudy/ContactCTA.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,25 @@ interface Props {
companyLogoAlt: string;
companyLogoWidth?: number;
companyLogoHeight?: number;
/** Logo already ships light-on-dark artwork — skip the white-flatten filter. */
companyLogoIsLightOnDark?: boolean;
}

const { companyLogo, companyLogoAlt, companyLogoWidth, companyLogoHeight } = Astro.props;
const { companyLogo, companyLogoAlt, companyLogoWidth, companyLogoHeight, companyLogoIsLightOnDark } = Astro.props;
---

<section class="cs-contact-cta">
<div class="cs-contact-bg" style="background-image: linear-gradient(0deg, rgba(0, 0, 0, 0.30) 0%, rgba(0, 0, 0, 0.30) 100%), url(https://img.thingsboard.io/case-studies/handshake.webp)">
<div class="cs-contact-outer-frame">
<div class="cs-contact-inner-frame">
<img class="cs-contact-logo-tb" src="https://img.thingsboard.io/case-studies/thingsboard.svg" alt="ThingsBoard logo" width="298" height="56" />
<img class="cs-contact-logo-company" src={companyLogo} alt={companyLogoAlt} width={companyLogoWidth} height={companyLogoHeight} />
<img
class:list={['cs-contact-logo-company', { 'cs-logo-unfiltered': companyLogoIsLightOnDark }]}
src={companyLogo}
alt={companyLogoAlt}
width={companyLogoWidth}
height={companyLogoHeight}
/>
</div>
</div>
</div>
Expand Down Expand Up @@ -135,6 +143,10 @@ const { companyLogo, companyLogoAlt, companyLogoWidth, companyLogoHeight } = Ast
z-index: 1;
filter: brightness(0) invert(1);

&.cs-logo-unfiltered {
filter: none;
}

@include media-down(md) {
max-height: 50px;
max-width: 200px;
Expand Down
182 changes: 182 additions & 0 deletions src/data/case-studies/cuba-iot-platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import type { CaseStudyData } from './types';

export const data: CaseStudyData = {
title: 'Backup power IoT in Kazakhstan: how CUBA IoT Platform monitors diesel generators with ThingsBoard',
pageTitle: 'How CUBA IoT Platform Monitors Diesel Generators',
description:
'CUBA IoT Platform connects diesel generator controllers to ThingsBoard via CUBA RePort gateways: fleet dashboards, alarms, fuel and readiness monitoring.',
pageSlug: 'cuba-iot-platform',
breadcrumb: 'CUBA IoT Platform — Facility Management',
categories: ['Facility management'],
logoIsLightOnDark: true,

hero: {
category: 'FACILITY MANAGEMENT',
heading: 'Backup power IoT in Kazakhstan: how CUBA IoT Platform monitors diesel generators with ThingsBoard',
paragraphs: [
'CUBA IoT Platform is a Kazakhstan-based IoT company and local ThingsBoard integrator. The company delivers online monitoring, real-time device control, data visualization, analytics, automation scenarios, and turnkey integration for distributed infrastructure. CUBA combines platform implementation, local engineering support, and proprietary hardware to help customers monitor equipment, reduce operational risk, and respond to events faster.',
'For backup power operators, diesel generator units are critical but often geographically distributed assets. A generator may remain idle for long periods, but it must start immediately when the main power supply fails. CUBA’s diesel generator monitoring solution connects generator controllers through CUBA RePort gateways and uses ThingsBoard dashboards, alarms, telemetry history, and rule chains to give operators continuous visibility into generator readiness.',
],
logo: '/images/case-studies/cuba-iot-platform-logo.png',
logoAlt: 'CUBA IoT Platform logo',
backgroundImage: '/images/case-studies/cuba-iot-platform.webp',
},

statistics: [
{ value: 100, suffix: '+', label: 'monitored generator nodes in production' },
{ value: 74, suffix: '+', label: 'telemetry and alarm parameters per generator profile' },
{ value: 4, suffix: '+', label: 'supported generator controller/OEM families' },
],

quote: {
text: 'With ThingsBoard and CUBA RePort gateways, diesel generators are no longer rarely inspected backup assets. Operators can see low fuel, discharged batteries, abnormal temperatures, fault states, missed test runs, and maintenance needs before they become outages.',
author: 'Dmitry Shkunov',
role: 'CTO at Skif Trade LLP (SMALL, SPAR supermarkets)',
},

problem: {
description:
'Organizations relying on diesel generators needed reliable visibility into generator readiness, actual runtime, alarms, fuel level, and maintenance status. Manual inspection could miss critical issues such as discharged batteries, low fuel, incorrect operating mode, heater failure, cooling problems, or unreported controller alarms. CUBA addressed these challenges by connecting generator controllers to ThingsBoard and converting raw telemetry into dashboards, alarms, historical trends, and operational notifications.',
challenges: [
'Limited visibility into geographically distributed diesel generators and their actual readiness',
'Manual checks did not reliably detect low fuel, battery voltage issues, incorrect operating modes, or controller fault states',
'Service teams needed maintenance planning based on engine hours, start counts, and real operating history instead of fixed assumptions',
'Operators needed immediate notifications when a generator failed, fuel changed unexpectedly, or a scheduled test run was missed',
],
results: [
'Continuous monitoring of fuel level, battery voltage, oil pressure, coolant temperature, generator/mains electrical parameters, runtime, start count, operating mode, and alarms',
'Centralized fleet table with sorting by site, generator type, operating status, fuel level, alarm count, coolant temperature, and control mode',
'Rule-based alarm activation and instant notifications through Telegram, SMS, and email',
'Historical trends for outage investigation, fuel refill tracking, generator start/stop analysis, maintenance planning, and operational reporting',
],
},

power: {
companyName: 'CUBA IoT Platform',
blocks: [
{
title: 'Gateway-based data acquisition',
text: 'CUBA RePort polls diesel generator controllers over Modbus RTU or Modbus TCP and transfers telemetry to the platform through MQTT/HTTP. The gateway supports Ethernet, GSM LTE, Wi-Fi, RS-485, UART, NTP time synchronization, built-in web configuration, DIN-rail mounting, and backup autonomy of at least 20 minutes. This makes the solution suitable for distributed sites where wired connectivity may be unavailable or unreliable.',
image: '/images/case-studies/cuba-iot-platform-1.webp',
imageAlt: 'CUBA RePort gateway that connects diesel generator controllers to ThingsBoard',
},
{
title: 'Unified asset model and dashboards',
text: 'ThingsBoard is configured around generator assets, sites, telemetry streams, alarms, and operator dashboards. Dispatchers can start from a fleet-level table, sort assets by status or alarm count, and drill down to a detailed generator page. The detailed panel consolidates engine state, generator electrical parameters, mains parameters, breaker states, runtime, and control mode.',
image: '/images/case-studies/cuba-iot-platform-2.webp',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think images 2 and 3 are paired with the wrong blocks. This block's text is about the asset model, the fleet table and the per-generator detail panel that "consolidates engine state, generator electrical parameters, mains parameters, breaker states, runtime, and control mode" — that is precisely what cuba-iot-platform-3.webp shows (left rail with Engine/Generator readings, Generator and Mains tables, breaker indicators). cuba-iot-platform-2.webp is four historical trend charts with an "Engine stop" annotation, which belongs with block 4. Your own alt strings say as much: image 2's is "Generator telemetry charts", image 3's is "…engine, generator, and mains panels".

Caveat: you built this 1:1 from the design, so it's quite possible the pairing is faithful and the mismatch originated there. In that case this is a question for the designer rather than something to fix in the PR — but the mismatch is real either way, so it's worth resolving before it ships.

Two related observations while you're here. Images 2 and 5 are both crops of the same historical dashboard that image 4 shows in full, so blocks 2, 4 and 5 currently render three near-identical chart grids — and a straight 2↔3 swap would leave the Rule Engine block holding image 2, which still shows no alarm or notification. A rule-chain or Telegram/e-mail notification screenshot would carry more weight there. Separately, image 3 has a Russian-language UI ("Карта" / "Таблица") on an otherwise English marketing page — worth an English capture if one is obtainable.

imageAlt: 'CUBA RePort generator monitoring dashboard with engine, generator, and mains panels',
},
{
title: 'Rule Engine alarms and operational notifications',
text: 'Rule chains process controller alarms, fuel level, battery voltage, coolant temperature, oil pressure, operating mode, and generator/mains state. Notifications are routed to responsible users through Telegram, SMS, and email. Each event can include the site, generator, source gateway, parameter, value, and timestamp, reducing the need for manual dashboard checks.',
image: '/images/case-studies/cuba-iot-platform-3.webp',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-raising the part of my earlier image note that the swap didn't cover. Blocks 2 and 3 now hold the right screenshots relative to each other, but this block is specifically about rule chains, alarm activation and Telegram/SMS/email notifications, and the image it lands on is four historical trend charts (coolant temperature, oil pressure, mains power, battery voltage) with an "Engine stop" annotation — no alarm, no rule chain, no notification anywhere in the frame.

So the strongest claim in the section is the one with no supporting visual. A rule-chain screenshot, an alarms table, or a Telegram/email notification capture would carry real weight here. If nothing like that is obtainable from the customer, dropping the image from this block would still read better than an unrelated one.

imageAlt: 'Generator and mains telemetry charts in a ThingsBoard dashboard',
},
{
title: 'Historical analytics and service workflows',
text: 'Time-series widgets show current and historical data for any selected period. Operators can analyze mains outages, generator starts, fuel refills, engine stops, voltage, current, power, oil pressure, coolant temperature, and battery behavior. This supports readiness proof, outage investigation, fuel theft detection, and maintenance planning based on actual runtime.',
image: '/images/case-studies/cuba-iot-platform-4.webp',
imageAlt: 'Historical charts of fuel level, coolant temperature, oil pressure, and power',
},
{
title: 'ThingsBoard implementation',
text: 'CUBA deployed ThingsBoard as the central monitoring layer for diesel generator assets. CUBA RePort gateways poll generator controllers over industrial protocols and publish structured telemetry to the platform. ThingsBoard stores telemetry, manages assets and dashboards, processes alarm rules, and sends notifications to responsible users.',
image: '/images/case-studies/cuba-iot-platform-5.webp',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other re-raise, now more visible than it was before the swap. Comparing the four screenshots: image 4 is the full 12-tile historical dashboard, and images 3 and 5 are both crops of that same dashboard — image 3 is its Coolant Temperature / Oil Pressure / Mains power / Battery Voltage tiles, image 5 its Mains current / Mains power / Generator current / Generator power tiles. Blocks 3, 4 and 5 therefore render three overlapping views of one dashboard, with image 4 containing both of the others.

What makes it worth acting on rather than filing as a nit is what the cropping costs. In image 5 the whole top row (Mains current, Mains power) sits flat at zero. In image 4 that same flat line is legible, because the annotation "Power network shutdown" is right there explaining it — mains died, which is the whole reason the generator ran. Cropped away from that annotation it just reads as an empty chart, so the crop loses the one thing that made the data tell a story.

And this block is the retitled architecture block — gateways polling controllers over Modbus and publishing telemetry. A dashboard crop, half of it flat, is a weak illustration for it; an architecture or topology diagram is the natural fit and would also break up three consecutive chart grids.

If the design supplies exactly these five crops then this is a question for the designer rather than a change to make here — but with block 2's screenshot re-exported already, 3/4/5 is now the weakest part of the page's visuals.

imageAlt: 'Mains and generator current and power charts in ThingsBoard',
},
],
},

help: {
industryName: 'backup power operations improve reliability with IoT',
blocks: [
{
title: 'Early detection of generator issues',
text: 'ThingsBoard helps detect problems before they lead to generator failure. Operators receive alerts when battery voltage drops, fuel level becomes too low, coolant temperature rises, oil pressure changes, or the controller reports an alarm.',
images: [
{
src: 'https://img.thingsboard.io/case-studies/sensors-widget.webp',
alt: 'ThingsBoard sensors widget',
title: 'ThingsBoard sensors widget',
},
{
src: 'https://img.thingsboard.io/case-studies/entities-table.webp',
alt: 'ThingsBoard entities table widget',
title: 'ThingsBoard entities table widget',
},
],
},
{
title: 'Reliable start readiness monitoring',
text: 'The platform continuously shows whether each generator is ready to start during a power outage. This helps identify common hidden risks such as discharged batteries, failed preheating, fuel shortage, manual mode, or active controller faults.',
images: [
{
src: 'https://img.thingsboard.io/case-studies/tables.webp',
alt: 'ThingsBoard tables widget',
title: 'ThingsBoard tables widget',
},
{
src: 'https://img.thingsboard.io/case-studies/bar-chart.webp',
alt: 'ThingsBoard bar chart widget',
title: 'ThingsBoard bar chart widget',
},
],
},
{
title: 'Event-based maintenance control',
text: 'Runtime hours, number of starts, alarms, and operating history are collected automatically. Service teams can plan maintenance based on real equipment usage and react faster when abnormal operation is detected.',
images: [
{
src: 'https://img.thingsboard.io/case-studies/line-chart.webp',
alt: 'ThingsBoard line chart widget',
title: 'ThingsBoard line chart widget',
},
{
src: 'https://img.thingsboard.io/case-studies/alarms-table.webp',
alt: 'ThingsBoard alarms table widget',
title: 'ThingsBoard alarms table widget',
},
],
},
{
title: 'Fuel level and usage visibility',
text: 'Fuel data is available remotely for every monitored generator. This helps detect sudden drops, refueling events, abnormal consumption, and possible fuel theft.',
images: [
{
src: 'https://img.thingsboard.io/case-studies/pie-chart.webp',
alt: 'ThingsBoard pie chart widget',
title: 'ThingsBoard pie chart widget',
},
{
src: 'https://img.thingsboard.io/case-studies/battery-level.webp',
alt: 'ThingsBoard battery level widget',
title: 'ThingsBoard battery level widget',
},
],
},
{
title: 'Instant notifications for responsible teams',
text: 'ThingsBoard sends alerts through configured notification channels when critical events occur. This reduces response time and allows service teams to act before the backup power system becomes unavailable.',
images: [
{
src: 'https://img.thingsboard.io/case-studies/alarms-table-2.webp',
alt: 'ThingsBoard alarms table widget',
title: 'ThingsBoard alarms table widget',
},
{
src: 'https://img.thingsboard.io/case-studies/notification-widget.webp',
alt: 'ThingsBoard notification widget',
title: 'ThingsBoard notification widget',
},
],
},
],
},

contact: {
companyLogo: '/images/case-studies/cuba-iot-platform-logo.png',
companyLogoAlt: 'CUBA IoT Platform logo',
companyLogoWidth: 240,
companyLogoHeight: 91,
},
};
7 changes: 6 additions & 1 deletion src/data/case-studies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const caseStudyCategories = [
// 2. Add the slug to this array in the position you want it displayed.
// 3. (Optional) If the card needs a custom logo height, add an entry to
// `cardLogoHeights` below.
// 4. (Optional) Logo already light-on-dark? Set `logoIsLightOnDark: true` in the data file.
export const caseStudyOrder: string[] = [
'ibt-systems',
'acte-technology',
Expand All @@ -29,6 +30,8 @@ export const caseStudyOrder: string[] = [
'onedata',
'lumen',
'solandtec',
// Intentionally out of newest-first order: first card of catalog page 2.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still unresolved: the fragility half is fixed — the client script reads data-page-size now, so the page size no longer lives in three unlinked places. But the part I'd flagged as the more important one is still open: the comment says what the placement does, not what it's for. The next person tidying this array back to newest-first has no way to tell whether that's safe, which is the failure mode the comment exists to prevent.

One clause would close it — why page 2 specifically, rather than the top of page 1 where a new case study would normally go? That reasoning is the only thing stopping someone from "fixing" the ordering later.

(No need to put the 9 back or export the constant — data-page-size was the right call, and it matches what PartnerLibrary.astro already does.)

'cuba-iot-platform',
'tjk-solutions',
'energenix',
'energroup',
Expand Down Expand Up @@ -61,7 +64,8 @@ export const caseStudyOrder: string[] = [

// Card-only logo height overrides. The hero on the detail page may want a
// different intrinsic size than the catalog card, so we keep the card override
// here rather than on the data itself.
// here rather than on the data itself. Sizing is per surface; the logo's
// treatment (`logoIsLightOnDark`) spans surfaces, so that one lives on the data.
const cardLogoHeights: Record<string, number> = {
ariot: 75,
iioote: 35,
Expand Down Expand Up @@ -120,6 +124,7 @@ function toCard(d: CaseStudyData): CaseStudyCard {
logo: d.hero.logo,
logoAlt: d.hero.logoAlt,
logoHeight: cardLogoHeights[d.pageSlug],
logoIsLightOnDark: d.logoIsLightOnDark,
backgroundImage: d.hero.backgroundImage,
};
}
Expand Down
8 changes: 8 additions & 0 deletions src/data/case-studies/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export interface CaseStudyData {
pageSlug: string;
breadcrumb: string;
categories: string[];
/**
* The company logo already ships light-on-dark artwork (e.g. a shaded
* white-on-transparent mark or a tinted SVG). Surfaces that normally
* white-flatten logos (catalog card, contact CTA) render it as-is instead.
*/
logoIsLightOnDark?: boolean;

hero: {
category: string;
Expand Down Expand Up @@ -126,5 +132,7 @@ export interface CaseStudyCard {
logo: string;
logoAlt: string;
logoHeight?: number;
/** See CaseStudyData.logoIsLightOnDark. */
logoIsLightOnDark?: boolean;
backgroundImage: string;
}
49 changes: 46 additions & 3 deletions src/data/partners/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,19 @@ export function getCoverage(d: Distributor): string[] {
return Array.from(new Set(d.regions.flatMap((r) => REGION_MEMBERSHIP[r]))).sort();
}

/** Regions the table files a country under. */
function regionsOf(country: string, membership: Record<Region, string[]>): Region[] {
return (Object.keys(membership) as Region[]).filter((r) => membership[r].includes(country));
}

/**
* The region table and the distributor data must describe the same countries.
* A named country the table doesn't classify silently narrows region-wide
* coverage; a classified country nobody covers is dead weight that hides typos.
* The region table and the distributor data must describe the same countries,
* and every distributor must declare every region its named countries fall
* under. A named country the table doesn't classify silently narrows
* region-wide coverage; a classified country nobody covers is dead weight that
* hides typos; a named country in an undeclared region can't be reached through
* that region's filter, since the dropdown offers only the region's own
* countries and a card only matches regions it declares.
*/
function findCoverageErrors(distributors: Distributor[], membership: Record<Region, string[]>): string[] {
const named = new Set(distributors.flatMap(getNamedCountries));
Expand All @@ -35,6 +44,19 @@ function findCoverageErrors(distributors: Distributor[], membership: Record<Regi
);
}

const undeclared = distributors.flatMap((d) => {
// Unclassified countries are already reported above.
const outside = getNamedCountries(d).filter(
(c) => classified.has(c) && regionsOf(c, membership).some((r) => !d.regions.includes(r))
);
return outside.length > 0 ? [`${d.name} (${outside.join(', ')})`] : [];
});
if (undeclared.length > 0) {
errors.push(
`countries in a region their distributor doesn't declare — add that region to the entry in src/data/partners/distributors.ts, or fix the country's classification in src/data/partners/regions.ts: ${undeclared.join('; ')}`
);
}

// Compares names, not coverage: a region-wide distributor covers these without naming them.
const stray = [...classified].filter((c) => !named.has(c)).sort();
if (stray.length > 0) {
Expand All @@ -52,10 +74,31 @@ function findCoverageErrors(distributors: Distributor[], membership: Record<Regi
return errors;
}

/**
* Declared regions that contain none of the entry's named countries. Such a
* card shows under the region's "All countries" view but disappears as soon as
* any country is picked, so this is worth a look — but whether the region is a
* genuine claim or a leftover is a data-owner call, hence a warning, not an error.
*/
function findDanglingRegions(distributors: Distributor[], membership: Record<Region, string[]>): string[] {
return distributors.flatMap((d) => {
const countries = getNamedCountries(d);
if (countries.length === 0) return [];
const dangling = d.regions.filter((r) => !countries.some((c) => membership[r].includes(c)));
return dangling.length > 0 ? [`${d.name} → ${dangling.join(', ')}`] : [];
});
}

/** Throws if the dataset and the region table disagree. Called by `distributors.ts`. */
export function assertDistributorData(distributors: Distributor[], membership: Record<Region, string[]>): void {
const errors = findCoverageErrors(distributors, membership);
if (errors.length > 0) {
throw new Error(`[distributors] ${errors.join(' | ')}`);
}
const dangling = findDanglingRegions(distributors, membership);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran this against the dataset as it stands on this branch and it fires immediately, for 8 entries:

[distributors] regions declared without any named country in them (card vanishes once a country is picked):
  Clever Platform → Asia; HADEX → Asia; LTS-Connecting Things → Asia; qnectd → Europe;
  Quantova → Asia; Spectrum Smart Solutions Systems LLC → Asia; Kar-Tel LLP → Middle East;
  IoT Experts → Asia

A check that's noisy on the day it lands stops carrying signal within a build or two, and then it won't catch the case it was written for. Six of the eight are the same shape — a Middle-East-only country (Saudi Arabia, Syria, UAE, Israel) with a leftover Asia claim.

I simulated dropping every dangling region to see what it would cost, and mechanically it costs nothing:

8 entries change (e.g. Clever Platform ["Middle East","Asia"] → ["Middle East"],
                       Kar-Tel LLP ["Asia","Europe","Middle East"] → ["Asia","Europe"])
assertion:                 passes, zero warnings
empty (region, country):   none
unreachable distributors:  none
country dropdowns:         identical — no option gained or lost

So the only real question is editorial: should those 8 cards keep showing under a region whose countries they don't serve, in the "All countries" view? That's a data-owner call, which is exactly why a warning rather than an error is the right shape here — I'd just rather the channel started silent. Clearing the HADEX one (see my note in distributors.ts) is yours since this PR created it; the other 7 are pre-existing and fine as a follow-up ticket.

If some of those claims turn out to be deliberate, there's a good in-repo precedent for saying so: src/data/case-studies/index.ts:97 keeps its equivalent warning quiet with an explicit FEATURED_ONLY_SLUGS allowlist.

if (dangling.length > 0) {
console.warn(
`[distributors] regions declared without any named country in them (card vanishes once a country is picked): ${dangling.join('; ')}`
);
}
}
Loading