npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stamhoofd/metabase

v2.152.0

Published

Everything that configures Metabase: the ledenstatistieken report and the API client that writes it.

Readme

Metabase

Everything that configures Metabase: the ledenstatistieken report and the API client that writes it.

| | | |---|---| | report/*.sql | The report, one file per tab, as queries on the platform statistics database | | src/report.ts | Reads those files. Knows nothing about Metabase | | src/api.ts | Minimal client for the Metabase HTTP API | | src/sync-report.ts | Says the report in Metabase's vocabulary and writes it | | src/naming.ts | What things are called in Metabase, so every caller agrees |

What the report counts

Two fragments decide that, and every card opens on one of them:

| | | |---|---| | includes/all-non-platform-registrations.sql | one row per registration: who was registered where, in which leeftijdsgroep, in which year | | includes/deduplicated-non-platform-registrations.sql | that narrowed to one row per member per eenheid, carrying what they are |

Each fragment is a complete query. It states its own dependencies -- deduplicated-non-platform-registrations opens a WITH on all-non-platform-registrations, which opens one on all-registrations -- so whoever reads one includes that one and nothing else, names it in a WITH (or a derived table) of their own, and never learns what it is built from. It also means every fragment runs as it stands: paste {{snippet: deduplicated-non-platform-registrations}} into a Metabase question on its own and the leden come back as a table, which is how a figure is investigated apart from the aggregates the cards draw over it. includes/deduplicated-non-platform-registrations-all-years.sql is the same over every werkjaar at once -- the year filter does not reach it, which is what the trend cards read; a test keeps its ranking identical to the single-year fragment's.

A fragment that is a condition rather than a query -- which aansluitingen count, which kinds of registration count -- carries a filter- prefix and is a bare predicate: the query that includes it writes the AND or WHERE around it, so it fits any place in a where-clause. A fragment says what it is on a -- description: first line, which becomes the snippet's description in Metabase rather than a comment in its sql.

The ledenstatistieken count members, not registrations, so every card of that dashboard reads deduplicated-non-platform-registrations and none reads the registrations directly. Someone registered in two leeftijdsgroepen of the same eenheid holds two registrations, and counted there they land in two bars of the same chart, or -- if they are leiding in one leeftijdsgroep and lid in the other -- on both sides of the omkaderingscijfer at once. That fragment picks the one registration that speaks for them: leiding beats lid, and the oldest leeftijdsgroep wins between two.

The aanlevering is the exception and keeps reading its own rows. The department counts inschrijvingen rather than inschrijvers, and reads what a member is from the cancelled registrations as well, so jeugdbewegingen.sql decides it in its own deelnemers.

Which registrations reach either of those is includes/filter-registration-types.sql: a registration stands in a leeftijdsgroep, an activiteit or a wachtlijst, and only the first two count as being a lid -- counting the wachtlijsten would put the people waiting for a place among the leden. The aanlevering pins itself further to the leeftijdsgroepen alone in jeugdbewegingen.sql.

One definition, in Metabase too

@include puts a fragment in one place in git. Snippets put it in one place in Metabase: every includes/*.sql is written as a snippet of its own, and a question refers to it -- {{snippet: deduplicated-non-platform-registrations}} where the file says -- @include deduplicated-non-platform-registrations, with it referring to {{snippet: all-non-platform-registrations}} rather than holding a second copy of those rows. A card is then the handful of lines that say what it counts instead of the two hundred that say what a lid is, and whoever changes what a lid is changes it once for the forty-odd questions that count one -- the questions the client writes themselves included, which can open on {{snippet: deduplicated-non-platform-registrations}} instead of on a copy pasted out of a card.

A question declares a tag for every fragment it reads, the ones it only reaches through another fragment included: Metabase resolves a nested {{snippet: all-registrations}} against the tags of the question it is running and not against those of the fragment that refers to it, so naming a fragment is not enough to reach what it reads. A snippet is also pointed at by id, which is why the fragments are written before the questions that read them.

Nothing here ever clears a fragment away. Metabase cannot delete a snippet, only archive it, and the name stays taken either way -- so a snippet is matched by name and updated in place, an archived one is brought back rather than written a second time, and one the report no longer has is left alone, since whatever has been built on it since would break with it.

The fragments are still expanded as well. card.sql is the whole query, which is what the tests read and what says whether the sql itself is right; card.snippetSql is that query with the fragments left as references, and is what Metabase is given.

One report, several platforms

The same report is written for every platform, and they do not all count every figure the same way: the GTP index weighs leeftijdsgroepen for keeo and the age of a lid for ravot, where leiding count as one and a half. A statistics database holds one platform, so which variant a card gets is decided while the report is loaded — loadReport(env) takes the environment, the same name the data source carries.

| | | |---|---| | report/includes/<env>/gtp.sql | what @include gtp expands to in that environment | | -- description@ravot: | what a card's -- description: says there |

A card names neither and keeps saying @include gtp, which is what keeps one report from quietly becoming two. An override of a name no fragment carries is refused rather than ignored: nothing includes it, so a misspelled file would change nothing and say nothing.

What a leeftijdsgroep counts as

Kinderen, leiding or volwassenen. Every figure that divides the one from the other reads it, and it is the one thing about a leeftijdsgroep that nothing in the administration knows: the platform configuration has no such field, and the ages do not answer it, since leiding and stam carry no age range and a leeftijdsgroep of kinderen need not carry one either.

includes/default-age-groups-with-category.sql is where it is settled, and includes/default-age-group-category.sql is the answer itself. includes/keeo/ and includes/ravot/ hold the lists that name that platform's leeftijdsgroepen by id; the unqualified fragment says nothing, which is what a platform that has not written its list is left with.

The statistics database used to hold a hand-filled category column for this instead. It is dropped: a column no rebuild survives, refilled by an UPDATE recorded nowhere, is not somewhere an answer can live. A list in the query is in git, is checked by the tests, and stands in front of whoever opens the question in Metabase. The cost is that it is now the whole answer — a leeftijdsgroep missing from its platform's list counts as nothing at all, so a leeftijdsgroep added to the platform configuration has to be added here too.

Ravot's list is the one to read before changing either. Its ondersteunende leden are volwassenen and may not be categorised as leiding: the omkaderingscijfer and the GTP index would then count them as leiding the kinderen of an eenheid are looked after by. The aanlevering delivers them among the leiding anyway — the department has no third word for them — by naming the leeftijdsgroep itself in ravot/type-deelnemers.sql, which only works for as long as the category does not say it.

Standing in front of the reader only holds until the next yarn metabase report, which rewrites every card from these files — an edit made in Metabase is a correction to bring back here, not a place to keep one.

One tab is not part of the client's own report. report/jeugdbewegingen.sql is the dataset the koepel delivers to the Departement Cultuur, Jeugd en Media every september, one card per sheet of the delivery template: tables to download as .xlsx and paste into it rather than charts to read. It names a dashboard: of its own, which writes it as a second dashboard in the collection instead of as a page of the ledenstatistieken. That is also why those cards name their columns -- Metabase writes the header of an export from the column's title, and a sheet read by a government department has to keep the names the template gives it.

The koepel's own organization is treated the other way around by the two. The ledenstatistieken leave it out by default -- it is the national body, not an eenheid, and the client's own report normally counts none of its structuurvrijwilligers -- but expose a boolean dashboard filter to include it. The aanlevering is about nothing else. platform.membershipOrganizationId is the only thing that says which organization that is, so the same rows exist as two fragments: includes/all-registrations.sql with the koepel, and includes/all-non-platform-registrations.sql -- those rows less the koepel unless the dashboard filter includes it -- built on top of it. The import writes the koepel under that same id, so both hold in the years imported from the client's own statistics as well as in the years the sync owns.

Installing Metabase differs per environment — Docker locally (stam platform-report start), a jar under systemd on a server (yarn metabase install in devops) — but configuring it is the same HTTP calls either way. That is why this package holds no install logic and no credentials: a caller passes the base URL, the credentials and the data source to write against.

| | Local | Server | |---|---|---| | Written by | stam platform-report dashboards | yarn metabase report -s keeo/keeo-metabase-001 | | Authenticates with | the admin account the CLI created | an api key from 1Password | | Data source | registered by the CLI | added once by hand, looked up by name |

Nothing this writes carries the name of a platform: the collection is Ledenstatistieken and holds the dashboards Ledenstatistieken and Aanlevering ..., whichever platform they count. A server holds one platform, and a local Metabase shows the report of the environment written last — writing another one points the same questions at its data source.

That is also why nothing here trashes a collection. There is one to write into, and a collection this wrote under the older per-environment name (Ledenstatistieken (keeo)) is renamed into it, keeping the id every question, dashboard, link and bookmark points at. The only thing a run ever trashes is a dashboard this tool wrote itself under a layout it no longer writes.

The two auth modes exist because the environments differ in what they may hold. Locally the CLI completes the setup wizard itself and knows the password. A server's first account is made by a person, and nothing may store their password, so it uses an api key created under Admin > Settings > Authentication > API keys. verifyApiKey() checks it belongs to an admin before anything is written, so a revoked key fails up front instead of halfway through the report.

It deliberately depends on no part of the backend, not even in its tests: the schema the cards query is owned by @stamhoofd/backend-statistics-syncer, and reaching for it here would make a shared package depend on an app. So the tests check the shape of the report — its tabs, cards, parameters and filters — and never run a card. A column renamed in the statistics migrations therefore surfaces in Metabase rather than here.

yarn stam test metabase