ReviewOS

also looking at this

stacks/analyticshq

fix(dashboard): the regions panel says California, not CA

#63
Merged glennmichael123 wants to merge fix/region-panel-shows-names into main
4 files +145 -9

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

app/Analytics/regions.tsmodified+77-0
Changes to app/Analytics/regions.ts
@@ -27,6 +27,8 @@
2727 */
2828
2929/** What a grouped region query hands back, before folding. */
30import SUBDIVISIONS from './subdivisions.json'
31
3032export interface RegionCount {
3133 region: string | null
3234 views: number | string
@@ -109,3 +111,78 @@ export function foldRegions(rows: readonly RegionCount[], floor: number, limit:
109111 withheld,
110112 }
111113}
114
115/**
116 * `US-CA` -> `California`, `PH-07` -> `Central Visayas`.
117 *
118 * ## Where the names come from
119 *
120 * `subdivisions.json` is a name -> code table per country, generated from the
121 * DB-IP City Lite file so `geo.ts` can turn the English name that database
122 * carries into an ISO code on the way in. Read the other way it is a code ->
123 * name table, which is what a reader needs on the way out.
124 *
125 * The panel used to print the bare code, and the comment where it did explained
126 * why: spelling a subdivision out "would mean shipping a table of every ISO
127 * 3166-2 subdivision name — some four thousand of them". That table is already
128 * shipped, and has been since region geo started working at all. `US-CA` at
129 * least reads as California by luck; `PH-07` reads as nothing, and `CA` beside a
130 * flag is equally the code for Canada.
131 *
132 * ## Why a name can be ambiguous, and which one wins
133 *
134 * DB-IP carries aliases, so 384 codes have more than one spelling — `AL-03` is
135 * both `Elbasan` and `Elbasan County`, `DE-BW` both `Baden-Württemberg` and the
136 * unaccented `Baden-Wurttemberg`. Reversing the map therefore needs a rule, and
137 * the obvious one — shortest wins — picks the mangled German.
138 *
139 * So: the spelling with the most non-ASCII characters wins, and the shortest
140 * breaks the tie. That is "prefer the properly accented name, then the plainest
141 * form of it", which gives Baden-Württemberg, Berlin over State of Berlin,
142 * Elbasan over Elbasan County, and Bicol over Bicol Region.
143 *
144 * Built once on first use: 194 countries and 3,234 subdivisions is not work to
145 * repeat per row, and a dashboard that never opens the panel never pays for it.
146 */
147let subdivisionNames: Map<string, string> | null = null
148
149function namesByCode(): Map<string, string> {
150 if (subdivisionNames)
151 return subdivisionNames
152
153 // Accented spelling first, then the shortest, so the winner is deterministic
154 // whatever order the table is in.
155 const preferred = (a: string, b: string): string => {
156 const accents = (s: string) => [...s].filter(c => c.charCodeAt(0) > 127).length
157 if (accents(a) !== accents(b))
158 return accents(a) > accents(b) ? a : b
159 return a.length <= b.length ? a : b
160 }
161
162 const table = SUBDIVISIONS as Record<string, Record<string, string>>
163 const built = new Map<string, string>()
164 for (const [country, names] of Object.entries(table)) {
165 for (const [name, code] of Object.entries(names)) {
166 const key = `${country}-${code}`
167 const held = built.get(key)
168 built.set(key, held ? preferred(held, name) : name)
169 }
170 }
171
172 subdivisionNames = built
173 return built
174}
175
176/**
177 * The English name for an ISO 3166-2 code, or null when the table has none.
178 *
179 * Null rather than a guess, and the caller keeps showing the code: a subdivision
180 * the table does not know is still a real place the visitor came from, and a row
181 * reading `PH-XX` is honest where an invented name would not be.
182 */
183export function subdivisionName(code: string | null | undefined): string | null {
184 const parts = splitRegion(code)
185 if (!parts)
186 return null
187 return namesByCode().get(`${parts.country}-${parts.subdivision}`) ?? null
188}
resources/views/dashboard.stxmodified+15-8
Changes to resources/views/dashboard.stx
@@ -17,7 +17,7 @@ import { CH_SEARCH, CH_SOCIAL, channelOf, channelPredicate, countryName, eventUr
1717import { installTargets } from '../../app/Analytics/install'
1818import { listReachableSites, listSiteMembers } from '../../app/Analytics/access'
1919import { planForSite } from '../../app/Analytics/entitlements'
20import { foldRegions, splitRegion } from '../../app/Analytics/regions'
20import { foldRegions, splitRegion, subdivisionName } from '../../app/Analytics/regions'
2121import { cityLabel, foldCities, splitCity } from '../../app/Analytics/cities'
2222import { liveSnapshot } from '../../app/Analytics/realtime'
2323import { niceCeil, xTicks, yTicks } from '../../app/Support/chart-axis'
@@ -983,14 +983,20 @@ const countryRows = countries.map((c) => {
983983 * per-row floor when no other breakdown on this page does. The traffic still
984984 * counts in the total; what is withheld is which state it came from.
985985 *
986 * ## Why the label is a code and not a name
986 * ## Why the label is a name and not a code
987987 *
988988 * `US-CA` is what the column holds, what addFilterUrl puts in the query string,
989 * and what the filter matches, so the code has to survive on the row. Spelling
990 * it out would mean shipping a table of every ISO 3166-2 subdivision name — some
991 * four thousand of them — to render a panel that is off by default. The flag
992 * carries the country and the code carries the state, which is how most of these
993 * are written down anyway.
989 * and what the filter matches — none of which is affected by how the row reads.
990 *
991 * It used to read as the bare code, because spelling it out "would mean shipping
992 * a table of every ISO 3166-2 subdivision name". That table is already shipped:
993 * `subdivisions.json`, which geo.ts has needed since region geo started working
994 * at all. Read backwards it answers this question for nothing.
995 *
996 * The code was only ever legible by accident. `US-CA` passes for California to
997 * an American reader; `PH-07` says nothing to anyone, and a bare `CA` beside a
998 * flag is equally the code for Canada. A subdivision the table cannot name still
999 * shows its code, which is honest about not knowing rather than inventing.
9941000 */
9951001const REGION_FLOOR = siteFloor
9961002const foldedRegions = foldRegions(regions, REGION_FLOOR, 8)
@@ -1013,7 +1019,8 @@ const regionRows = foldedRegions.rows.map((r) => {
10131019 // built out of the wrong two letters.
10141020 if (!parts)
10151021 return { ...r, label: r.region }
1016 return { ...r, label: regionCountry ? parts.subdivision : `${flag(parts.country)} ${parts.subdivision}` }
1022 const named = subdivisionName(r.region) ?? parts.subdivision
1023 return { ...r, label: regionCountry ? named : `${flag(parts.country)} ${named}` }
10171024})
10181025if (foldedRegions.other) {
10191026 regionRows.push({
tests/unit/region-geo.test.tsmodified+8-1
Changes to tests/unit/region-geo.test.ts
@@ -180,10 +180,17 @@ describe('the dashboard panel is off until a site turns it on', () => {
180180 })
181181
182182 test('inside one country the flag is dropped, since every row would repeat it', () => {
183 expect(view).toContain('regionCountry ? parts.subdivision : `${flag(parts.country)} ${parts.subdivision}`')
183 expect(view).toContain('regionCountry ? named : `${flag(parts.country)} ${named}`')
184184 expect(view).toContain('`Regions in ${countryName(regionCountry)}`')
185185 })
186186
187 test('a row is labelled by name, falling back to the code', () => {
188 // `PH-07` says nothing to a reader and `CA` beside a flag is equally
189 // Canada, so the panel prints the subdivision's name. A code the table
190 // cannot name keeps showing the code rather than inventing one.
191 expect(view).toContain('subdivisionName(r.region) ?? parts.subdivision')
192 })
193
187194 test('the toggle is :checked + @change, never x-model', () => {
188195 // x-model would own the value, and the value is the server's until a request
189196 // succeeds — an optimistic tick that then failed would leave the box saying
tests/unit/subdivision-name.test.tsadded+45-0
Changes to tests/unit/subdivision-name.test.ts
@@ -0,0 +1,45 @@
1import { describe, expect, it } from 'bun:test'
2import { subdivisionName } from '../../app/Analytics/regions'
3
4/**
5 * The regions panel reads `PH-07` out of the database and has to print
6 * something a person recognises. These pin the lookup, and the tie-break it
7 * needs, because `subdivisions.json` is regenerated from DB-IP and a rebuild
8 * that changed either would quietly change what every row says.
9 */
10describe('subdivisionName', () => {
11 it('names the codes the panel actually shows', () => {
12 expect(subdivisionName('US-CA')).toBe('California')
13 expect(subdivisionName('PH-07')).toBe('Central Visayas')
14 expect(subdivisionName('GB-ENG')).toBe('England')
15 })
16
17 it('keeps the accented spelling when the table carries both', () => {
18 // DB-IP has 'Baden-Wurttemberg' and 'Baden-Württemberg' against DE-BW, and
19 // the obvious tie-break — shortest wins — picks the mangled one.
20 expect(subdivisionName('DE-BW')).toBe('Baden-Württemberg')
21 })
22
23 it('prefers the plainest form when neither spelling is accented', () => {
24 expect(subdivisionName('DE-BE')).toBe('Berlin')
25 expect(subdivisionName('PH-05')).toBe('Bicol')
26 })
27
28 it('answers null rather than guessing, so the row keeps its code', () => {
29 // A real place the table does not know. `PH-XX` on screen is honest; an
30 // invented name is not.
31 expect(subdivisionName('PH-XX')).toBeNull()
32 expect(subdivisionName('US')).toBeNull()
33 expect(subdivisionName('')).toBeNull()
34 expect(subdivisionName(null)).toBeNull()
35 expect(subdivisionName('nonsense')).toBeNull()
36 })
37
38 it('covers enough of the table to be worth having', () => {
39 // A guard on the data rather than the code: a regenerated file that lost
40 // most of its countries would still pass every assertion above.
41 const named = ['US-CA', 'US-NY', 'GB-ENG', 'DE-BY', 'FR-IDF', 'JP-13', 'AU-NSW', 'PH-07', 'CA-ON', 'BR-SP']
42 for (const code of named)
43 expect(subdivisionName(code)).toBeTruthy()
44 })
45})