also looking at this
fix(dashboard): the regions panel says California, not CA
#63
4 files
+145
-9
| @@ -27,6 +27,8 @@ | ||
| 27 | 27 | */ |
| 28 | 28 | |
| 29 | 29 | /** What a grouped region query hands back, before folding. */ |
| 30 | import SUBDIVISIONS from './subdivisions.json' | |
| 31 | ||
| 30 | 32 | export interface RegionCount { |
| 31 | 33 | region: string | null |
| 32 | 34 | views: number | string |
| @@ -109,3 +111,78 @@ export function foldRegions(rows: readonly RegionCount[], floor: number, limit: | ||
| 109 | 111 | withheld, |
| 110 | 112 | } |
| 111 | 113 | } |
| 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 | */ | |
| 147 | let subdivisionNames: Map<string, string> | null = null | |
| 148 | ||
| 149 | function 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 | */ | |
| 183 | export 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 | } | |
| @@ -17,7 +17,7 @@ import { CH_SEARCH, CH_SOCIAL, channelOf, channelPredicate, countryName, eventUr | ||
| 17 | 17 | import { installTargets } from '../../app/Analytics/install' |
| 18 | 18 | import { listReachableSites, listSiteMembers } from '../../app/Analytics/access' |
| 19 | 19 | import { planForSite } from '../../app/Analytics/entitlements' |
| 20 | import { foldRegions, splitRegion } from '../../app/Analytics/regions' | |
| 20 | import { foldRegions, splitRegion, subdivisionName } from '../../app/Analytics/regions' | |
| 21 | 21 | import { cityLabel, foldCities, splitCity } from '../../app/Analytics/cities' |
| 22 | 22 | import { liveSnapshot } from '../../app/Analytics/realtime' |
| 23 | 23 | import { niceCeil, xTicks, yTicks } from '../../app/Support/chart-axis' |
| @@ -983,14 +983,20 @@ const countryRows = countries.map((c) => { | ||
| 983 | 983 | * per-row floor when no other breakdown on this page does. The traffic still |
| 984 | 984 | * counts in the total; what is withheld is which state it came from. |
| 985 | 985 | * |
| 986 | * ## Why the label is a code and not a name | |
| 986 | * ## Why the label is a name and not a code | |
| 987 | 987 | * |
| 988 | 988 | * `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. | |
| 994 | 1000 | */ |
| 995 | 1001 | const REGION_FLOOR = siteFloor |
| 996 | 1002 | const foldedRegions = foldRegions(regions, REGION_FLOOR, 8) |
| @@ -1013,7 +1019,8 @@ const regionRows = foldedRegions.rows.map((r) => { | ||
| 1013 | 1019 | // built out of the wrong two letters. |
| 1014 | 1020 | if (!parts) |
| 1015 | 1021 | 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}` } | |
| 1017 | 1024 | }) |
| 1018 | 1025 | if (foldedRegions.other) { |
| 1019 | 1026 | regionRows.push({ |
| @@ -180,10 +180,17 @@ describe('the dashboard panel is off until a site turns it on', () => { | ||
| 180 | 180 | }) |
| 181 | 181 | |
| 182 | 182 | 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}`') | |
| 184 | 184 | expect(view).toContain('`Regions in ${countryName(regionCountry)}`') |
| 185 | 185 | }) |
| 186 | 186 | |
| 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 | ||
| 187 | 194 | test('the toggle is :checked + @change, never x-model', () => { |
| 188 | 195 | // x-model would own the value, and the value is the server's until a request |
| 189 | 196 | // succeeds — an optimistic tick that then failed would leave the box saying |
| @@ -0,0 +1,45 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { 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 | */ | |
| 10 | describe('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 | }) | |