Add ACP IT Solutions AG, rename AppBuilder to Tektonics Systems - #615
Conversation
Two partner-requested changes to the distributor list. ACP is a new entry. The South African partner listed as AppBuilder (Pty.) Ltd. now trades as Tektonics Systems, with a new contact address and domain but unchanged coverage. ACP's stated coverage is EMEA, which has no single value here — the filter model is seven fixed regions plus an explicit country list — so it maps to Europe, Middle East and Africa with no countries. The card therefore shows up under those three region filters but drops out as soon as a visitor picks a specific country, since the country filter requires a match. It is the only entry of the hundred without countries; a country list from the partner would close that gap. Its website keeps the /de-at path it was supplied with rather than being normalised to a bare domain like its neighbours, so the link lands on the Austrian page directly. The renamed entry moves out of the A-cluster to sit between Techno Pynė and Tesla Water: the page renders array order without sorting, so alphabetical position is maintained by hand.
There was a problem hiding this comment.
Pull request overview
Updates the partners/distributors dataset used by the /partners/distributors page by adding a new distributor entry and renaming an existing distributor (with updated contact details) to reflect partner-requested changes.
Changes:
- Added a new distributor entry for ACP IT Solutions AG (regions: Europe/Middle East/Africa).
- Renamed AppBuilder (Pty.) Ltd. to Tektonics Systems and updated email/website while keeping coverage the same.
- Adjusted the in-array placement to preserve the page’s manual alphabetical ordering.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| name: "ACP IT Solutions AG", | ||
| regions: ["Europe","Middle East","Africa"], | ||
| countries: [], | ||
| email: "development.hzb@acp.de", | ||
| website: "acp-gruppe.com/de-at", |
There was a problem hiding this comment.
Went with (b), with the geography made explicit rather than inferred.
regionWide: true now marks an entry as covering its whole regions, and the build expands that into the concrete countries those regions contain. The client keeps one filtering rule, and data-countries is never empty for such an entry — so no wildcard branch, and no second region-aware code path to drift from the server's view.
Worth recording why the mapping is a new table rather than derived from what was already here: regionCountries looks like a country→region map, but it is a cross-product of each entry's countries with each of its regions. Teknei (Europe + North America + South America) lists the United States, so the USA lands in Europe's country set and an EMEA partner would have matched it. Deriving per country from the intersection of its entries' regions excludes the USA correctly, but then loses Belarus, Moldova, Armenia and Azerbaijan, which no single-region entry claims. Hence src/data/partners/regions.ts: the 168 countries the dataset uses, classified once.
Three checks now fail the build rather than degrade quietly, which also covers the case you were guarding against — someone adding an entry and simply forgetting the countries:
- an entry with no countries and no
regionWide; - a country absent from the table;
- table keys that are not exactly
REGIONS.
Expansion is deliberately kept out of allCountries, so the dropdown's options are unchanged by this PR. ACP now matches 67 EMEA countries and nothing outside them — verified in the built HTML, which also confirms the guards execute.
ACP serves EMEA, which the data model could not express: coverage was an enumerated country list, and leaving it empty dropped the card out of every country filter, since filtering tests membership of data-countries and an empty attribute splits to [""]. Adds regionWide to Distributor. Coverage for such an entry is expanded at build time into the concrete countries its regions contain, so the client keeps a single filtering rule — a card matches if its country list contains the selection — rather than growing a second, region-aware path that could drift from the server's view. The expansion is deliberately kept out of allCountries: it widens what an existing dropdown option matches and adds no options, so the finder's dropdown behaves exactly as before. Expanding needs to know which countries a region contains, and nothing in the repo knew. regionCountries only looks like that map; it is a cross-product of each entry's countries with each of its regions, so Teknei (Europe, North America, South America) files the United States under Europe and an EMEA partner would match it. Deriving per country from the intersection of its entries' regions fixes that but loses Belarus, Moldova, Armenia and Azerbaijan, which no single-region entry claims. Hence an explicit table: the 168 countries the data uses, classified once. Transcontinental countries carry every region they span, so Turkey counts as Europe and Middle East. Middle East stays a region of its own rather than a subset of Asia, matching how the existing entries are written. Three checks fail the build rather than degrade quietly, because every failure here is invisible on the page — a card silently missing from a filter looks identical to a partner who does not cover that country. An entry with no countries and no regionWide throws, a country absent from the table throws, and the table's keys must be exactly the finder's regions. Normalises three country names the table would otherwise have had to enshrine: Swizerland to Switzerland, and the redundant Lietuvos Respublika and Kingdom of the Netherlands, each of which sat alongside the canonical spelling in the same entry and appeared as its own dropdown option.
vvlladd28
left a comment
There was a problem hiding this comment.
Review summary
Reviewed 4 changed files in Add ACP IT Solutions AG, rename AppBuilder to Tektonics Systems. Left 9 comment(s) inline.
The data changes and the reasoning behind the explicit table both hold up under checking: REGION_MEMBERSHIP's union is exactly the 168 countries the dataset uses with no extras in either direction, ACP expands to exactly 67 countries, alphabetical placement of both entries is right, and no distributor currently carries a region name outside REGIONS. Deriving coverage at build time is also the right call over pasting the 67 names into the data file. The substantive comments are about where that derivation and its guards live, one guard that fails open, and the fact that the new table now competes with the pre-existing regionCountries cross-product.
Additional findings
These observations are about existing code outside the PR's diff — spotted while reading surrounding context.
- src/pages/partners/distributors.astro:145 — Syria is the one place where the new table and the existing dropdown disagree visibly.
REGION_MEMBERSHIPfiles it under Middle East, but HADEX (its only lister) isAsia, soregionCountriesoffers Syria only in the Asia group. Selecting Middle East therefore never offers Syria, and selecting Asia + Syria excludes ACP. Every other table pair is reachable. This dissolves if the dropdown moves ontoREGION_MEMBERSHIPas suggested onsrc/data/partners/regions.ts:16.
This review was auto-generated. Findings may contain errors — please verify before applying changes.
| import { DISTRIBUTORS, REGIONS } from '~/data/partners/distributors'; | ||
| import { REGION_MEMBERSHIP } from '@data/partners/regions'; | ||
|
|
||
| const uncovered = DISTRIBUTORS.filter((d) => !d.regionWide && d.countries.length === 0); |
There was a problem hiding this comment.
Deriving the expansion at build time is the right call over pasting 67 country names into the ACP entry — the data would then encode a snapshot instead of the intent ("all of EMEA") and would rot silently as the dataset grows. What I'd question is this page's frontmatter as the home for it.
The repo already has a convention for exactly this: src/data/use-cases/index.ts and src/data/case-studies/index.ts both validate invariants and export derived shapes at module load, and src/data/partners/hardware-partners.ts exposes derived selectors (getSortedPartners(), getPartnerBySlug()). After this PR, distributors.astro is the only file under src/pages/ that throws.
Would you consider moving the three guards plus the expansion into src/data/partners/ — say an index.ts re-exporting DISTRIBUTORS, REGIONS and a derived coverage map or getCoverage(d)? A module that throws at import time still fails the build, but it fails for every consumer, so a future map or partner page can't reintroduce the unexpanded-coverage bug. It also makes the logic importable without rendering, and lets the messages pick up the [distributors] prefix the other two collections use.
| // Fail closed: an unclassified country would quietly narrow a region-wide | ||
| // partner's coverage instead of erroring. | ||
| const classified = new Set(Object.values(REGION_MEMBERSHIP).flat()); | ||
| const unclassified = Array.from(new Set(DISTRIBUTORS.flatMap((d) => d.countries))).filter( |
There was a problem hiding this comment.
Two things about this check.
It's one-directional: it catches a dataset country missing from the table, but not a table entry that's absent from the dataset. The two sides do match exactly right now (168 both ways, I checked), but a typo introduced into REGION_MEMBERSHIP later — "Untied Arab Emirates" — would expand into a country nobody can select and quietly narrow a region-wide partner's coverage. That's the same invisible failure the guards exist to catch, and the reverse check is one more line over the same two sets.
Separately, Array.from(new Set(DISTRIBUTORS.flatMap((d) => d.countries))) is computed here and again as allCountries 26 lines down. Hoisting the one set and having both read from it keeps them from drifting apart later, and the guard's intent reads more clearly as "every country the finder can offer must be classified" when it's literally checking allCountries.
| (c) => !classified.has(c), | ||
| ); | ||
| if (unclassified.length > 0) { | ||
| throw new Error(`Countries missing from REGION_MEMBERSHIP: ${unclassified.join(', ')}`); |
There was a problem hiding this comment.
Worth thinking about when these guards actually fire. There's no test suite in this repo, so build-time throws are a reasonable substitute — but GitHub Actions runs astro check, eslint and lint:slugcheck, none of which evaluate this page's frontmatter. The throw only happens in a full astro build, which currently lives in the separate linkcheck pipeline.
Probably good enough today. But the repo's own pattern for data invariants that must fail fast is a standalone script in scripts/ (lint-slugcheck.mjs, check-redirect-chains.ts) wired into the fast lane. If the guards move into src/data/partners/, a small scripts/lint-distributors.ts importing that module would give the same checks in CI — and would be the natural place to assert the interesting cases: a region-wide entry expands to its regions' union, a normal entry is untouched, an unclassified country trips the guard.
| // country their regions contain. Deliberately not fed into allCountries above: | ||
| // expansion widens what an existing dropdown option matches, it adds no options. | ||
| const filterCountries = d.regionWide | ||
| ? Array.from(new Set([...d.countries, ...d.regions.flatMap((r) => REGION_MEMBERSHIP[r] ?? [])])).sort() |
There was a problem hiding this comment.
REGION_MEMBERSHIP[r] ?? [] is the one place here that fails open, and it's reachable. Nothing validates that a distributor's regions are actual REGIONS members — the field is plain string[] — so a typo on a region-wide entry ("Middle east") contributes zero countries at this line. If that's its only region, filterCountries comes out empty, data-countries renders as "", and ''.split('|') yields [''] on the client, so the card drops out of every country filter. That's precisely the failure mode the first guard was written to prevent, except the guard passes because regionWide is set — and the region filter would silently miss the card too.
Cheapest fix is a fourth guard over DISTRIBUTORS.flatMap((d) => d.regions). Better: both this and the REGION_MEMBERSHIP-keys guard become compile errors caught by astro check if REGIONS gets as const plus export type Region = (typeof REGIONS)[number], then Record<Region, string[]> on the table and regions: Region[] on Distributor. I checked every entry — none has a bad region today, so that retype should be clean.
| const mailHref = isEmail ? `mailto:${d.email}?subject=Introduction request` : ''; | ||
| return { | ||
| ...d, | ||
| filterCountries, |
There was a problem hiding this comment.
The mapped object now carries both countries (raw, empty for a region-wide partner) and filterCountries (expanded), which makes it possible for a future template edit to reach for the wrong one and quietly render a card that matches nothing. Since nothing downstream uses the raw list, overriding it in the spread (countries: filterCountries) — or dropping ...d for an explicit view model — removes the choice entirely. If you'd rather keep both, coverageCountries reads better than filterCountries, which names the consumer rather than the thing.
| { | ||
| distributors.map((d) => ( | ||
| <div class="distributor-box" data-regions={d.regions.join('|')} data-countries={d.countries.join('|')}> | ||
| <div class="distributor-box" data-regions={d.regions.join('|')} data-countries={d.filterCountries.join('|')}> |
There was a problem hiding this comment.
Minor, but the expansion moves ~1KB of country names into the HTML per region-wide card (67 names for ACP), on top of the region-countries JSON blob the page already ships — and it scales with region-wide partners × region size, so an APAC-wide plus an Americas-wide entry would add a few more KB to a page that recently got LCP attention.
If those entries multiply, the shape to reach for is emitting data-region-wide on the card and letting filterAndUpdate treat it as matching when the selected country appears in one of the region lists it already has in REGION_COUNTRIES — it's already reading data-regions, so it'd be a couple of lines and no expanded attribute at all. Not worth it for a single partner.
| regions: string[]; | ||
| countries: string[]; | ||
| /** Serves every country in its regions; leave countries empty. */ | ||
| regionWide?: boolean; |
There was a problem hiding this comment.
This flag only means anything in combination with countries, and the invariant ("leave countries empty") lives in a doc comment plus a runtime guard rather than in the type. The guard covers the empty-countries-without-flag half; nothing describes regionWide: true with a non-empty countries, which the expansion silently unions into a third, undocumented mode.
Have you considered expressing coverage as one field so the states are exclusive by construction? countries: string[] | 'region-wide' is the smallest change and reads well at the call site; a discriminated coverage: { kind: 'countries'; countries: string[] } | { kind: 'regions' } is more explicit if you expect a third mode later. Either shape makes the first build-time guard unnecessary and stops a future entry from being written with countries: [] and no flag in the first place.
| * counts as Europe and Middle East, and Georgia as Europe and Asia. | ||
| * - Caribbean and Central American territories count as North America. | ||
| */ | ||
| export const REGION_MEMBERSHIP: Record<string, string[]> = { |
There was a problem hiding this comment.
This table and the pre-existing regionCountries cross-product in the page are now two answers to the same question, and this is the correct one. Its union is exactly the 168 countries the dataset uses — a clean classification of the same universe regionCountries approximates by unioning the country lists of every distributor that serves a region. That approximation is badly off: picking Europe in the finder offers 102 countries, 47 of which aren't in Europe; North America offers 108, of which 67 belong elsewhere (they leak in via multi-region entries like IoT Squad listing Europe + North America).
Since you're paying to maintain the authoritative mapping anyway, could the dropdown be built from it instead — REGION_MEMBERSHIP[region] intersected with the countries actually covered — deleting the regionCountries loop? That makes the table earn its keep twice. It's a visible change to dropdown contents, so it may genuinely belong in the follow-up you flagged rather than here; mostly I want to name the duplication before it ossifies, since right now the accurate map decides which cards match while the inaccurate one decides what the user can pick.
| * finder can actually offer need an entry here, and the distributors page fails | ||
| * the build when a distributor introduces one that is missing. | ||
| * | ||
| * Conventions: |
There was a problem hiding this comment.
Documenting the classification conventions here is the genuinely valuable part — it's what a reviewer can't otherwise verify. The cost worth naming in the same comment is that this table's union now has to stay equal to the dataset's country union (it is, exactly 168 both ways), so every ordinary "add a distributor" edit that introduces a new country becomes a two-file change. The guard makes that loud rather than silent, which is the right trade, but a line like "adding a country to any distributor requires adding it here" would put the instruction in front of the next person before the build error does.
The alternative shape would be Record<string, Region[]> keyed by country: one row each, no duplicated entries for the four transcontinental cases, and region → countries derivable at load. It reads less like the geography though, so I'd only switch if the duplicates start disagreeing.
Review feedback on where the region-wide coverage checks belong, and it was right on both counts. The checks lived in the finder page's frontmatter, which made that page the only file under src/pages/ that throws, and meant a future map or partner page could reintroduce the unexpanded-coverage bug while the page it was written for stayed correct. Worse, frontmatter only runs during a full astro build, and the GitHub Actions lane runs none — so checks written to fail closed never ran on a pull request at all. src/data/partners/index.ts now owns them, validating as it loads and exporting the derived selectors, the way src/data/use-cases/ and src/data/case-studies/ already do. Consumers import @data/partners, so the dataset fails for every consumer rather than for whoever remembered to check. scripts/lint-distributors.ts plus a ci.yml job put that in the fast lane. Three of the four checks stopped being runtime checks. Coverage collapses into one field — countries: [string, ...string[]] | 'region-wide' — which makes the two states exclusive by construction and an empty list a compile error, so the regionWide flag and its "leave countries empty" doc comment are both gone. The non-empty tuple is what earns that: a plain string[] union would still admit []. REGIONS becomes as const with a Region type, so a region name typo is a compile error too, and Record<Region, string[]> pins the table's keys. That last one closes a hole this branch shipped: nothing validated a distributor's regions, so 'Middle east' on a region-wide entry contributed no countries, rendered an empty data-countries, and dropped the card out of every filter — the exact failure the checks exist to catch, passing them because the flag was set. The ?? [] fallback that made it silent is gone; the lookup is total now. Both lint-script fixtures were mutation-tested, because the first version could not fail. It passed [fixture] as the whole distributor list, so every real country became unclassified and the stray-country branch alone satisfied the assertion — deleting the missing-country check entirely still left it green. Each fixture now perturbs the real data by one country and asserts the offending name appears in the message, so disabling either half of findCoverageErrors fails that half and only that half. CLAUDE.md's CI list named three jobs and would have been wrong the moment this merged; it now names four, and the distributor finder gets a section stating the two-file rule the coverage check enforces.
Both sides appended a quality-check command to the same line of CLAUDE.md's list — main's lint:dualrender and this branch's lint:distributors. Kept both. lint:dualrender is deliberately not wired into any pipeline, per its own note in the CI Checks section, so the distributors job added here is still the only new GitHub Actions job and that section's list stays accurate.
The lint script and its GitHub Actions job cost a full checkout and pnpm install to run a 0.10s check that needs no dependencies, on every PR and every push, while src/data/partners/ changes a few times a year. Drop both and let the import-time assertion carry it. To keep that trade favourable, the one invariant the script was the only thing catching becomes a compile error: regions is now a non-empty tuple, so an entry with no regions no longer type-checks. It would otherwise render a card invisible under every filter, on a green build. The assertion also moves next to the data it constrains — distributors.ts calls assertDistributorData from the new coverage.ts, following assertNewestFirst in src/models/. Importing @data/partners/distributors directly no longer bypasses validation. Also: - the stray-country error says "names" rather than "covers" (it compares names, and a region-wide distributor covers without naming), leads with the non-destructive remedy, and names the distributors that would lose coverage if you drop them - COVERED_COUNTRIES -> OFFERED_COUNTRIES: it holds the countries distributors name, not everything covered - the per-region dropdown lists move into the data module as REGION_OFFERED_COUNTRIES, so getNamedCountries leaves the barrel and getCoverage is the only per-distributor accessor consumers can reach - populateCountries reads the server-rendered <select> instead of re-deriving the country union in the browser - CLAUDE.md: correct where the guard lives, the barrel's scope, and the Prettier settings (120 / es5, per .prettierrc) Dropdown contents are byte-for-byte unchanged.
Two partner-requested updates to the distributor list, plus the capability the first of them turned out to need.
Partner changes
ACP IT Solutions AG—development.hzb@acp.de,acp-gruppe.com/de-at, EMEA coverage. Inserted after ACORDE to hold the A-cluster's alphabetical order. Itswebsitekeeps the supplied/de-atpath rather than being normalised to a bare domain, so the link lands on the Austrian page directly.AppBuilder (Pty.) Ltd.→Tektonics Systems, with the new addresstshepo@tektonics.africaand domaintektonics.africa. Coverage unchanged (Africa/South Africa) as requested. The entry moves out of the A-cluster to sit between Techno Pynė and Tesla Water — the page renders array order without sorting, so alphabetical position is maintained by hand. Noappbuilderreferences remain insrc/orpublic/.Region-wide coverage
ACP serves EMEA, which the data model could not express: coverage was an enumerated country list, and leaving it empty dropped the card out of every country filter, since filtering tests membership of
data-countriesand an empty attribute splits to[""].Coverage is now one field. A distributor either lists its countries or sets
countries: 'region-wide':Why an explicit table rather than deriving it
regionCountriesonly looks like a country→region map. It is a cross-product of each entry's countries with each of its regions, so Teknei (Europe+North America+South America, lists the USA) files the United States under Europe.regionCountries)src/data/partners/regions.tsclassifies the 168 countries the dataset uses into the seven finder regions. Transcontinental countries carry every region they span (Turkey → Europe + Middle East; Armenia, Azerbaijan, Georgia → Europe + Asia), and Middle East stays a region of its own rather than a subset of Asia, matching how the existing entries are written.Where the invariants live
Every failure mode here is invisible on the page — a card missing from a filter looks identical to a partner who does not cover that country — so none of it is left to convention. Three of the four checks are now compile errors:
regions: Region[], fromREGIONS as const[string, ...string[]]rejects[]Record<Region, string[]>findCoverageErrors, at import timesrc/data/partners/index.tsowns the last one and the derived selectors (getCoverage,getNamedCountries,COVERED_COUNTRIES), matching howsrc/data/use-cases/andsrc/data/case-studies/validate at module load. Consumers import@data/partners, so the checks fail for every consumer rather than only for the page that happens to remember them.scripts/lint-distributors.ts+ adistributorsjob inci.ymlrun this in the GitHub Actions lane, which previously ran no full build and therefore never evaluated the page's frontmatter at all.Country-name normalisation
Swizerland→Switzerland, and the redundantLietuvos RespublikaandKingdom of the Netherlandsare gone — each sat alongside the canonical spelling in the same entry and appeared as its own dropdown option. The table would otherwise have had to enshrine them. The dropdown goes from 171 options to 168.Verification
pnpm build:fast— full build, which exercises the page and its data module across every route.pnpm checkclean (0 errors / 0 warnings / 0 hints); ESLint clean;pnpm lint:distributorspasses (100 distributors, 168 countries offered, 1 covering whole regions).regions: ['Middle east']andcountries: []produced exactly twopnpm checkerrors, including "Did you mean'Middle East'?"; injecting an unclassified country into a distributor madelint:distributorsexit 1 with an actionable message. Both fixtures in the lint script were mutation-tested — disabling either half offindCoverageErrorsfails that half's assertion and only that one.Follow-ups, not in this PR
REGION_MEMBERSHIP, so selecting Europe stops offering the USA (102 offered today, 47 of them not European). This also resolves Syria being offered only under Asia.