people-cities-js — People and Cities (MINT)

A frontend for MINT — Measurement INterface Translator, a Backend For Frontend that converts measurements between unit environments and declares the units it used. See The backend: MINT below.

Features

Resizing the grid

The grid sits in a src/vanilla-resize-box/ (aria-label="Resizable people grid" on the People tab, "Resizable cities grid" on Cities). Drag the corner handle to resize it — resizing is pointer-driven, with no keyboard path.

The handle carries role="separator" and takes its accessible name from the host's aria-label — e.g. "Resizable people grid — resize handle" — but is not focusable, so it adds no tab stop to the page. This app's markup needs nothing beyond the aria-label it already carries.

Structure

people-cities-js/
├── index.html                    # Main HTML page (both tab panels + toolbars + shared detail panel)
├── styles.css                    # Layout styling (visuals come from themes/)
├── themes/                       # App theme stylesheets (app-<name>.css)
├── app.js                        # Bootstrap only: locale, event wiring, tab switching
├── modules/
│   ├── index.js                  # Barrel consumed by app.js only (modules import siblings directly)
│   ├── state.js                  # Constants (endpoints, preference keys) + locale/threshold state
│   ├── dom-refs.js               # data-ref driven DOM reference map (collectRefs/getDomRefs)
│   ├── utils.js                  # Pure helpers (parsing, metadata resolution, preference restore, formatHometownValue)
│   ├── i18n.js                   # Locale bundles, t(), number formatters, static-UI localization
│   ├── preferences.js            # localStorage save/restore for the controls
│   ├── theme.js                  # App + grid theme switching (themes every <vn-grid>)
│   ├── bff.js                    # MINT headers, memoized $metadata, thresholds, DataManager/grid factories
│   ├── toolbar-helpers.js        # Shared toolbar routing: status, conversion info, export, row-count
│   ├── detail-panel.js           # Shared person-detail panel (render, open/close, view-selection); temporal fields render via VanillaGridDateTimeFeature
│   ├── people-tab.js             # People tab: grid init, loading, header units, thermometer, handlers
│   └── cities-tab.js             # Cities tab: grid init, loading, combined Hometown column, handlers
└── README.md                     # This file

Both tabs follow the same architecture: an ODataDataManager (infinite scroll, 1000 rows/page, server-side sort/search) built by bff.js's createPeopleODataManager(), grid options from buildSharedGridOptions(), a toolbar wired inside the tab module (init<Tab>Tab()), and a load<Tab>Rows() / refresh<Tab>() / handle<Tab>EnvironmentChange() public surface. Sort, column-filter, and search changes re-fetch page 0 automatically: each fires a config-change on the manager and the grid (setAutoReloadOnConfigChange(true)) coalesces them into one reload — so onSortChanged is a notification hook (it just logs), not a reload trigger.

How It Works

  1. Environment selection — the user picks a measurement environment (Metrical / Imperial / Custom) from the dropdown. The choice is sent as an X-MU-Environment header on every MINT request and persisted to localStorage.
  2. Metadata fetchbff.js requests (and memoizes) the OData $metadata for the selected environment, resolving each property's unit and decimal places from MINT's requested declaration.
  3. Headers with units — grid headers show each property's unit for the active environment (e.g. Height in m vs ft).
  4. Data load — each tab's ODataDataManager loads rows automatically (loadPeopleRows() / loadCitiesRows()), with infinite scroll, server-side sort, and server-side free-text search folded into $filter.

The backend: MINT

This app is a frontend for MINT — Measurement INterface Translator (github.com/lucamenazzi/mint), which implements the Backend For Frontend (BFF) pattern: it sits between the browser and an internal OData service and reshapes that service's responses for this specific client.

What MINT translates is measurements. The internal service stores each quantity in one fixed unit; MINT converts values per request into whatever unit the caller's environment uses, and declares those units so the client can label and format them. The frontend therefore performs no unit conversion of its own — it renders what it is given and reads the units out of $metadata.

The mechanism is a single request header, X-MU-Environment: metrical | imperial | custom. The same row, same query, two environments:

GET /odata-c/users?$top=1     X-MU-Environment: metrical
  → { "Id": "00000343-…", "Height": 156.97,   "Weight": 94.28,   "BodyTemperature": 36.5 }

GET /odata-c/users?$top=1     X-MU-Environment: imperial
  → { "Id": "00000343-…", "Height": 514.9934, "Weight": 207.8518, "BodyTemperature": 98 }

$metadata reports both sides of that translation per property — what the backend has (configured) and what this caller asked for (requested), including the decimal places each unit should be rendered with:

"height": [{
  "configured": { "environment": "metrical", "quantity": "Length", "unit": "m",  "decimalPlaces": 2 },
  "requested":  { "environment": "imperial",                      "unit": "ft", "decimalPlaces": 4 }
}]

That document is what drives two visible grid behaviours: the unit shown beside each header (column.secondaryLabel) and the numeric precision (column.formatOptions) — both applied by updatePeopleHeaderUnits() in modules/people-tab.js. Switching the environment dropdown re-fetches metadata and rows, and the headers relabel themselves.

Prerequisites

None. MINT is hosted at https://people-cities-app.mthome.org and answers with Access-Control-Allow-Origin: *, so the browser reaches it directly from whatever origin serves this app — there is no backend to start locally.

Like the other sample apps that hit a public API, this one needs network access to show rows; offline, the grids stay empty and the toolbar reports the fetch error.

Run

Serve the workspace root with any static server, then open /samples/people-cities-js/index.html. For example:

python -m http.server 8080     # then open http://localhost:8080/samples/people-cities-js/
# or
npx http-server -p 8080

The Playwright suite auto-starts its own static server on port 4173 (see playwright.config.js) — no manual server needed to run the tests.

Testing

Two kinds of Playwright spec cover this app, and the split is deliberate:

BACKEND_URL_PATTERNS in _helpers.js must stay in sync with MINT_BASE_URL below. If it stops matching, the hermetic specs do not fail — they silently start making real network calls.

API integration

All requests carry the X-MU-Environment header. Endpoints (base URL and paths are defined in modules/state.js):

GET  https://people-cities-app.mthome.org/odata-c/users/$metadata   # per-property configured vs requested units + decimal places
GET  https://people-cities-app.mthome.org/odata-c/users             # People rows, measurements already translated
                                                        # (OData $skip/$top/$orderby/$filter)
GET  https://people-cities-app.mthome.org/odata-c/users/$count      # total row count
POST https://people-cities-app.mthome.org/odata-c/users/$convert    # translate specific values between units

$convert is the one call that is not a grid data source: the body-temperature thermometer cell has fixed thresholds expressed in °C, and loadTemperatureThresholds() in modules/bff.js posts them to MINT to get them back in the active environment's unit rather than converting them client-side.

MINT annotates its responses with X-MU-ConvertionStrategy (the conversion strategy applied, e.g. traditional), X-Metadata-Api (where to fetch the unit declarations for this endpoint), and X-MU-ResponseSizeKB.

displayConversionStrategy() in modules/toolbar-helpers.js renders those into each toolbar's <vn-grid-toolbar-info> ⓘ tooltip from the DataManager's onFetchResponse hook. Because the tooltip describes the response the rows on screen came from, each tab clears it while a full load is in flight — the first load and every reload a column filter, a sort, the search box or the reload command triggers — so a stale strategy never appears to describe rows that are being replaced. This is deliberately app-specific, not a shared helper: it is wired per tab in modules/people-tab.js and modules/cities-tab.js as a vn-grid-loading listener, that event being exactly the set of loads meant (loadRowsAsync() is its only emitter). An infinite-scroll page — reported through vn-grid-load-more-succeeded / -failed — leaves the tooltip up: those rows are appended, not replaced. No restore wiring is needed — the next response re-fills it, and a failed load correctly leaves it hidden.

The Cities tab is a second grid over the same /odata-c/users endpoint with a different column projection (see modules/cities-tab.js).

Customization

Browser compatibility

Modern browsers (Chrome, Firefox, Edge, Safari) with ES module and Fetch API support.

styles.css sets overscroll-behavior-y: none on html/body alongside the height: 100dvh app shell — the host-owed half of the grid's mobile touch contract that stops document-level rubber-band bounce and pull-to-refresh on touch devices (see docs/vanilla-grid/00-index.md, "Mobile / touch integration").