From aee324b9cb5f955c5cbeecf8812e571f9785f384 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 24 Oct 2026 13:25:20 +0200 Subject: [PATCH 1/2] feat(region-filter): configurable named region quick-pick groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relates to #416. Operators can define optional named groups of observer-region codes in config.json under `regionGroups`; the region filter renders each as one button that selects that group's codes in a single action. Server: `regionGroups` is normalized once at load (at most 12 groups, 24 distinct codes per group, 40-character names; trimming and case-insensitive de-duplication applied, every other violation drops the entry with one log line) and served by the new GET /api/config/regions/groups, filtered per request to the codes /api/config/regions actually offers. The flat code-to-label map that endpoint serves is a documented contract, so the groups get their own path instead of a field inside it. No groups configured serves [] and the filter behaves exactly as before. Frontend: the quick picks go through one shared selection path with the region pills (applySelection), so a real change saves, re-renders and notifies listeners once, and a no-op — picking the group already shown, or clearing an already-clear filter — notifies nobody and makes no page refetch. They are buttons with aria-pressed, themed through the existing CSS variables, 48 px on touch devices and a full-width row of their own at 390 px. --- cmd/server/config.go | 152 ++++++++ cmd/server/coverage_test.go | 1 + cmd/server/openapi.go | 7 +- cmd/server/region_groups_416_test.go | 444 +++++++++++++++++++++++ cmd/server/routes.go | 23 +- cmd/server/routes_test.go | 1 + config.example.json | 5 + docs/api-spec.md | 51 +++ proto/config.proto | 20 + public/region-filter.js | 210 +++++++++-- public/style.css | 44 +++ scripts/e2e-shards.json | 8 + test-all.sh | 1 + test-issue-416-region-quick-picks-e2e.js | 348 ++++++++++++++++++ test-issue-416-region-quick-picks.js | 390 ++++++++++++++++++++ 15 files changed, 1670 insertions(+), 35 deletions(-) create mode 100644 cmd/server/region_groups_416_test.go create mode 100644 test-issue-416-region-quick-picks-e2e.js create mode 100644 test-issue-416-region-quick-picks.js diff --git a/cmd/server/config.go b/cmd/server/config.go index 21dfd41d1..8a5296c9a 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -253,6 +253,19 @@ type Config struct { Regions map[string]string `json:"regions"` + // RegionGroups are operator-named quick picks over the observer-region + // codes the region filter offers (#416). Optional: an absent or empty + // list means the filter behaves exactly as it did before the feature + // existed. Normalized once at load by normalizeRegionGroups (see the + // caps and the drop/normalize rule there) and filtered per request + // against the served region map by regionGroupsForRegions, so a group + // never offers a code no observer reports. + // + // These are convenience labels over region codes, not verified + // geographic boundaries: "Bay Area" means "the codes the operator + // listed", nothing more. + RegionGroups []RegionGroup `json:"regionGroups,omitempty"` + Roles map[string]interface{} `json:"roles"` HealthThresholds *HealthThresholds `json:"healthThresholds"` Map map[string]interface{} `json:"map"` @@ -466,6 +479,144 @@ func (c *Config) ClientRxCoverageEnabled() bool { return c != nil && c.ClientRxCoverage != nil && c.ClientRxCoverage.Enabled } +// RegionGroup is one named quick pick over observer-region codes (#416). +type RegionGroup struct { + Name string `json:"name"` + Codes []string `json:"codes"` +} + +// Bounds on the `regionGroups` config (#416). The quick picks render as one +// wrapped row of buttons next to the region control, so the group count is +// what keeps that row readable on a 390 px screen rather than a scroll trap; +// 12 is roughly two rows there and well past what an operator curates by +// hand. regionGroupCodesMax covers a large metro cluster (a dozen observers +// per city) while keeping one pick's `region=` parameter short. The name cap +// is what fits a button without wrapping mid-word at that width. +const ( + regionGroupsMax = 12 + regionGroupCodesMax = 24 + regionGroupNameMax = 40 +) + +// normalizeRegionGroups returns the usable groups from the raw `regionGroups` +// config, in configured order, and reports every entry it drops through warn. +// +// A structural violation drops the whole entry: no name, a name longer than +// regionGroupNameMax runes, no codes, more than regionGroupCodesMax distinct +// codes, a name already used by an earlier kept group, or more than +// regionGroupsMax groups. Silently filtering by a subset of a too-long code +// list, or labelling a group with a truncated name, would mislabel the filter +// the operator asked for, so it is refused instead. +// +// Trimming and case-insensitive de-duplication are normalization, not +// violations: they are applied and never warned about. Codes are kept in +// their first configured spelling — the served spelling is decided per +// request by regionGroupsForRegions, because the region map includes observer +// codes that only exist in the database. +// +// An absent or empty list returns nothing and warns about nothing, so a +// deployment without the option is silent at startup as before. +func normalizeRegionGroups(in []RegionGroup, warn func(string, ...interface{})) []RegionGroup { + if len(in) == 0 { + return nil + } + out := make([]RegionGroup, 0, len(in)) + seenName := make(map[string]bool, len(in)) + overflow := 0 + for _, g := range in { + name := strings.TrimSpace(g.Name) + if name == "" { + warn("[region-groups] dropping a group with no name") + continue + } + if len([]rune(name)) > regionGroupNameMax { + warn("[region-groups] dropping group %q: name is %d characters, the maximum is %d", + name, len([]rune(name)), regionGroupNameMax) + continue + } + codes := make([]string, 0, len(g.Codes)) + seenCode := make(map[string]bool, len(g.Codes)) + for _, c := range g.Codes { + c = strings.TrimSpace(c) + if c == "" { + continue + } + key := strings.ToUpper(c) + if seenCode[key] { + continue + } + seenCode[key] = true + codes = append(codes, c) + } + if len(codes) == 0 { + warn("[region-groups] dropping group %q: it lists no region code", name) + continue + } + if len(codes) > regionGroupCodesMax { + warn("[region-groups] dropping group %q: %d region codes, the maximum is %d", + name, len(codes), regionGroupCodesMax) + continue + } + if seenName[strings.ToUpper(name)] { + warn("[region-groups] dropping a repeated group name %q: the first one stands", name) + continue + } + if len(out) >= regionGroupsMax { + overflow++ + continue + } + seenName[strings.ToUpper(name)] = true + out = append(out, RegionGroup{Name: name, Codes: codes}) + } + if overflow > 0 { + warn("[region-groups] dropping %d group(s) past the maximum of %d", overflow, regionGroupsMax) + } + if len(out) == 0 { + return nil + } + return out +} + +// regionGroupsForRegions filters normalized groups down to what a client can +// actually select: the codes present in `regions` (the same effective map +// GET /api/config/regions serves, config entries plus every observer IATA in +// the database), matched case-insensitively and returned in that map's +// spelling so a quick pick's codes compare equal to the region control's own. +// +// Codes no observer reports are dropped, because the region list is partly +// dynamic — an operator may configure a group for an observer that is not up +// yet, and a code that matches nothing would be an unselectable pill. A group +// left with no code at all is omitted rather than rendered as a no-op. +// +// Always returns a non-nil slice, so the endpoint serves [] and not null. +func regionGroupsForRegions(groups []RegionGroup, regions map[string]string) []RegionGroup { + out := make([]RegionGroup, 0, len(groups)) + if len(groups) == 0 || len(regions) == 0 { + return out + } + byUpper := make(map[string]string, len(regions)) + for code := range regions { + byUpper[strings.ToUpper(code)] = code + } + for _, g := range groups { + codes := make([]string, 0, len(g.Codes)) + seen := make(map[string]bool, len(g.Codes)) + for _, c := range g.Codes { + served, ok := byUpper[strings.ToUpper(strings.TrimSpace(c))] + if !ok || seen[served] { + continue + } + seen[served] = true + codes = append(codes, served) + } + if len(codes) == 0 { + continue + } + out = append(out, RegionGroup{Name: g.Name, Codes: codes}) + } + return out +} + // ClientRegionsConfig gates the opt-in declared-region reports (#126). type ClientRegionsConfig struct { Enabled bool `json:"enabled"` @@ -672,6 +823,7 @@ func LoadConfig(baseDirs ...string) (*Config, error) { cfg.NormalizeTimestampConfig() cfg.migrateDeprecatedConfig() cfg.applyListLimitsDefaults() + cfg.RegionGroups = normalizeRegionGroups(cfg.RegionGroups, log.Printf) applyCORSEnv(cfg) cfg.loadHashRegionsFile(p) return cfg, nil diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 14b095437..c3c8a3688 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -2000,6 +2000,7 @@ func TestHandleConfigEndpoints(t *testing.T) { "/api/config/cache", "/api/config/client", "/api/config/regions", + "/api/config/regions/groups", "/api/config/theme", "/api/config/map", } diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 9a1b0e227..83b25832b 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -47,9 +47,10 @@ type paramMeta struct { func routeDescriptions() map[string]routeMeta { return map[string]routeMeta{ // Config - "GET /api/config/cache": {Summary: "Get cache configuration", Tag: "config"}, - "GET /api/config/client": {Summary: "Client-visible operator configuration", Description: "Includes estimatedPositions: {enabled: boolean}, the effective server-startup policy for neighbor-derived position estimates. Missing server configuration defaults to enabled; explicit false suppresses these estimates in node detail, live and archived paths, and estimate-dependent analytics. Reported GPS and independent IATA/name-match observer positioning are unchanged. Restart the server to change the policy.", Tag: "config"}, - "GET /api/config/regions": {Summary: "Get configured regions", Tag: "config"}, + "GET /api/config/cache": {Summary: "Get cache configuration", Tag: "config"}, + "GET /api/config/client": {Summary: "Client-visible operator configuration", Description: "Includes estimatedPositions: {enabled: boolean}, the effective server-startup policy for neighbor-derived position estimates. Missing server configuration defaults to enabled; explicit false suppresses these estimates in node detail, live and archived paths, and estimate-dependent analytics. Reported GPS and independent IATA/name-match observer positioning are unchanged. Restart the server to change the policy.", Tag: "config"}, + "GET /api/config/regions": {Summary: "Get configured regions", Tag: "config"}, + "GET /api/config/regions/groups": {Summary: "Get configured region quick-pick groups", Tag: "config"}, "GET /api/regions/observed": {Summary: "Observed scoped relay positions", Description: "Bulk Regions map snapshot with selectable 1h, 24h or 7d window. Includes unpositioned scoped relays, current identity visibility filters, conservative retained-history coverage and explicit node/scope truncation flags. Reported positions are self-reported coordinates; neighbor estimates are excluded. Window bounds end at generatedAt, the shared relay snapshot time (normally refreshed every five minutes).", Tag: "analytics", Response: schemaRef("RegionsObservedResponse"), QueryParams: []paramMeta{{Name: "window", Description: "1h, 24h (default), or 7d", Type: "string"}}, Errors: []routeError{{Status: "400", Description: "Invalid window."}, {Status: "500", Description: "Current identity or node metadata could not be read."}, {Status: "503", Description: "Relay snapshot is unavailable or warming up."}}}, diff --git a/cmd/server/region_groups_416_test.go b/cmd/server/region_groups_416_test.go new file mode 100644 index 000000000..7c7412e13 --- /dev/null +++ b/cmd/server/region_groups_416_test.go @@ -0,0 +1,444 @@ +// #416 — configurable named region quick-pick groups. +// +// The config side of the feature: `regionGroups` in config.json is an +// optional, bounded list of named groups of observer-region codes, and +// GET /api/config/regions/groups serves the groups a client can actually +// select (codes filtered against the same effective region map that +// GET /api/config/regions returns). +// +// The rule the tests below pin: a structural violation (no name, a name over +// the length cap, no codes, more codes than the cap, a repeated name, more +// groups than the cap) DROPS the offending entry, and normalization (trim, +// case-insensitive de-duplication, removal of codes no observer reports) is +// the only thing that ever rewrites an entry. Silently filtering by a subset +// of a too-long code list, or by a truncated name, would mislabel a filter. +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeRegionGroupsConfig drops a config.json into dir for LoadConfig. +func writeRegionGroupsConfig(t *testing.T, dir, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0o644); err != nil { + t.Fatalf("write config.json: %v", err) + } +} + +// collectWarnings returns a warn func for normalizeRegionGroups plus the +// messages it produced, so the tests can assert that a dropped entry is not +// dropped silently. +func collectWarnings() (func(string, ...interface{}), *[]string) { + var msgs []string + return func(format string, v ...interface{}) { + msgs = append(msgs, fmt.Sprintf(format, v...)) + }, &msgs +} + +func groupNames(gs []RegionGroup) []string { + out := make([]string, 0, len(gs)) + for _, g := range gs { + out = append(out, g.Name) + } + return out +} + +func assertGroups(t *testing.T, got []RegionGroup, want []RegionGroup) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d groups %v, want %d %v", len(got), groupNames(got), len(want), groupNames(want)) + } + for i := range want { + if got[i].Name != want[i].Name { + t.Errorf("group %d: name %q, want %q", i, got[i].Name, want[i].Name) + } + if strings.Join(got[i].Codes, ",") != strings.Join(want[i].Codes, ",") { + t.Errorf("group %d (%s): codes %v, want %v", i, want[i].Name, got[i].Codes, want[i].Codes) + } + } +} + +// --- absent config: exactly today's behaviour --- + +func TestNormalizeRegionGroupsAbsent(t *testing.T) { + for _, tc := range []struct { + name string + in []RegionGroup + }{ + {"nil", nil}, + {"empty", []RegionGroup{}}, + } { + t.Run(tc.name, func(t *testing.T) { + warn, msgs := collectWarnings() + got := normalizeRegionGroups(tc.in, warn) + if len(got) != 0 { + t.Errorf("got %v, want no groups", groupNames(got)) + } + if len(*msgs) != 0 { + t.Errorf("absent config warned: %v", *msgs) + } + }) + } +} + +func TestLoadConfigNormalizesRegionGroups(t *testing.T) { + dir := t.TempDir() + writeRegionGroupsConfig(t, dir, `{ + "port": 3000, + "regions": {"SJC": "San Jose", "SFO": "San Francisco"}, + "regionGroups": [ + {"name": " Bay Area ", "codes": ["sjc", " SFO ", "SJC"]}, + {"name": "", "codes": ["SJC"]} + ] + }`) + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + assertGroups(t, cfg.RegionGroups, []RegionGroup{{Name: "Bay Area", Codes: []string{"sjc", "SFO"}}}) +} + +func TestLoadConfigWithoutRegionGroups(t *testing.T) { + dir := t.TempDir() + writeRegionGroupsConfig(t, dir, `{"port": 3000, "regions": {"SJC": "San Jose"}}`) + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if len(cfg.RegionGroups) != 0 { + t.Errorf("got %v, want no groups", groupNames(cfg.RegionGroups)) + } +} + +// --- invalid entries are dropped, loudly --- + +func TestNormalizeRegionGroupsDropsInvalidEntries(t *testing.T) { + for _, tc := range []struct { + name string + entry RegionGroup + }{ + {"empty name", RegionGroup{Name: "", Codes: []string{"SJC"}}}, + {"blank name", RegionGroup{Name: " ", Codes: []string{"SJC"}}}, + {"nil codes", RegionGroup{Name: "Bay", Codes: nil}}, + {"empty codes", RegionGroup{Name: "Bay", Codes: []string{}}}, + {"blank codes only", RegionGroup{Name: "Bay", Codes: []string{" ", ""}}}, + } { + t.Run(tc.name, func(t *testing.T) { + warn, msgs := collectWarnings() + got := normalizeRegionGroups([]RegionGroup{tc.entry, {Name: "Keep", Codes: []string{"SFO"}}}, warn) + assertGroups(t, got, []RegionGroup{{Name: "Keep", Codes: []string{"SFO"}}}) + if len(*msgs) != 1 { + t.Errorf("want exactly one warning, got %v", *msgs) + } + }) + } +} + +// --- duplicates --- + +func TestNormalizeRegionGroupsDuplicateNames(t *testing.T) { + warn, msgs := collectWarnings() + got := normalizeRegionGroups([]RegionGroup{ + {Name: "Bay Area", Codes: []string{"SJC"}}, + {Name: " bay area ", Codes: []string{"SFO"}}, + {Name: "BAY AREA", Codes: []string{"OAK"}}, + {Name: "SoCal", Codes: []string{"LAX"}}, + }, warn) + // First spelling wins; the later repeats are dropped, not merged. + assertGroups(t, got, []RegionGroup{ + {Name: "Bay Area", Codes: []string{"SJC"}}, + {Name: "SoCal", Codes: []string{"LAX"}}, + }) + if len(*msgs) != 2 { + t.Errorf("want one warning per dropped repeat, got %v", *msgs) + } +} + +func TestNormalizeRegionGroupsDuplicateCodes(t *testing.T) { + warn, msgs := collectWarnings() + got := normalizeRegionGroups([]RegionGroup{ + {Name: "Bay Area", Codes: []string{"SJC", "sjc", " SJC ", "SFO", "Sfo"}}, + }, warn) + // De-duplication is normalization, not a violation: the group survives + // with the first spelling of each code, in configured order. + assertGroups(t, got, []RegionGroup{{Name: "Bay Area", Codes: []string{"SJC", "SFO"}}}) + if len(*msgs) != 0 { + t.Errorf("de-duplication must not warn, got %v", *msgs) + } +} + +// --- limits --- + +func TestNormalizeRegionGroupsLimits(t *testing.T) { + t.Run("too many groups", func(t *testing.T) { + in := make([]RegionGroup, 0, regionGroupsMax+3) + for i := 0; i < regionGroupsMax+3; i++ { + in = append(in, RegionGroup{Name: fmt.Sprintf("G%02d", i), Codes: []string{"SJC"}}) + } + warn, msgs := collectWarnings() + got := normalizeRegionGroups(in, warn) + if len(got) != regionGroupsMax { + t.Fatalf("kept %d groups, want %d", len(got), regionGroupsMax) + } + if got[0].Name != "G00" || got[regionGroupsMax-1].Name != fmt.Sprintf("G%02d", regionGroupsMax-1) { + t.Errorf("wrong groups kept: %v", groupNames(got)) + } + if len(*msgs) != 1 { + t.Errorf("want one overflow warning, got %v", *msgs) + } + }) + + t.Run("group at the group cap is kept", func(t *testing.T) { + in := make([]RegionGroup, 0, regionGroupsMax) + for i := 0; i < regionGroupsMax; i++ { + in = append(in, RegionGroup{Name: fmt.Sprintf("G%02d", i), Codes: []string{"SJC"}}) + } + warn, msgs := collectWarnings() + got := normalizeRegionGroups(in, warn) + if len(got) != regionGroupsMax { + t.Fatalf("kept %d groups, want %d", len(got), regionGroupsMax) + } + if len(*msgs) != 0 { + t.Errorf("exactly the cap must not warn, got %v", *msgs) + } + }) + + t.Run("too many codes drops the group", func(t *testing.T) { + codes := make([]string, 0, regionGroupCodesMax+1) + for i := 0; i <= regionGroupCodesMax; i++ { + codes = append(codes, fmt.Sprintf("C%02d", i)) + } + warn, msgs := collectWarnings() + got := normalizeRegionGroups([]RegionGroup{ + {Name: "Huge", Codes: codes}, + {Name: "Exact", Codes: codes[:regionGroupCodesMax]}, + }, warn) + assertGroups(t, got, []RegionGroup{{Name: "Exact", Codes: codes[:regionGroupCodesMax]}}) + if len(*msgs) != 1 { + t.Errorf("want one warning for the dropped group, got %v", *msgs) + } + }) + + t.Run("repeats do not count against the code cap", func(t *testing.T) { + codes := make([]string, 0, regionGroupCodesMax+4) + for i := 0; i < regionGroupCodesMax; i++ { + codes = append(codes, fmt.Sprintf("C%02d", i)) + } + codes = append(codes, "c00", "C01", "c02", "C03") + warn, _ := collectWarnings() + got := normalizeRegionGroups([]RegionGroup{{Name: "Dedup", Codes: codes}}, warn) + if len(got) != 1 || len(got[0].Codes) != regionGroupCodesMax { + t.Fatalf("got %d groups / %v, want one group of %d codes", len(got), got, regionGroupCodesMax) + } + }) + + t.Run("name over the length cap drops the group", func(t *testing.T) { + warn, msgs := collectWarnings() + long := strings.Repeat("æ", regionGroupNameMax+1) + exact := strings.Repeat("ø", regionGroupNameMax) + got := normalizeRegionGroups([]RegionGroup{ + {Name: long, Codes: []string{"SJC"}}, + {Name: exact, Codes: []string{"SFO"}}, + }, warn) + // Counted in runes: a 41-character Danish name is over the cap, a + // 40-character one is not, no matter how many bytes they take. + assertGroups(t, got, []RegionGroup{{Name: exact, Codes: []string{"SFO"}}}) + if len(*msgs) != 1 { + t.Errorf("want one warning for the dropped group, got %v", *msgs) + } + }) +} + +// TestRegionGroupLimitsAreTheDocumentedNumbers pins the caps as literals. +// The behavioural cases above are built from the constants, so they move with +// them: on their own, widening a cap would not fail a single test. These are +// the numbers docs/api-spec.md, the api-spec table and config.example.json +// promise an operator, so changing one is a documentation change too. +func TestRegionGroupLimitsAreTheDocumentedNumbers(t *testing.T) { + for _, tc := range []struct { + name string + got int + want int + }{ + {"regionGroupsMax", regionGroupsMax, 12}, + {"regionGroupCodesMax", regionGroupCodesMax, 24}, + {"regionGroupNameMax", regionGroupNameMax, 40}, + } { + if tc.got != tc.want { + t.Errorf("%s = %d, want %d (update docs/api-spec.md and config.example.json too)", + tc.name, tc.got, tc.want) + } + } + + t.Run("the 13th group is dropped", func(t *testing.T) { + in := make([]RegionGroup, 0, 13) + for i := 0; i < 13; i++ { + in = append(in, RegionGroup{Name: fmt.Sprintf("G%02d", i), Codes: []string{"SJC"}}) + } + warn, _ := collectWarnings() + got := normalizeRegionGroups(in, warn) + if len(got) != 12 || groupNames(got)[11] != "G11" { + t.Errorf("kept %v, want the first 12", groupNames(got)) + } + }) + + t.Run("a 25-code group is dropped, a 24-code one is kept", func(t *testing.T) { + codes := make([]string, 0, 25) + for i := 0; i < 25; i++ { + codes = append(codes, fmt.Sprintf("C%02d", i)) + } + warn, _ := collectWarnings() + got := normalizeRegionGroups([]RegionGroup{ + {Name: "Twenty five", Codes: codes}, + {Name: "Twenty four", Codes: codes[:24]}, + }, warn) + if len(got) != 1 || got[0].Name != "Twenty four" { + t.Errorf("kept %v, want only the 24-code group", groupNames(got)) + } + }) + + t.Run("a 41-character name is dropped, a 40-character one is kept", func(t *testing.T) { + warn, _ := collectWarnings() + got := normalizeRegionGroups([]RegionGroup{ + {Name: strings.Repeat("a", 41), Codes: []string{"SJC"}}, + {Name: strings.Repeat("b", 40), Codes: []string{"SFO"}}, + }, warn) + if len(got) != 1 || got[0].Name != strings.Repeat("b", 40) { + t.Errorf("kept %d group(s), want only the 40-character one", len(got)) + } + }) +} + +// --- unknown codes: filtered per request against the effective region map --- + +func TestRegionGroupsForRegions(t *testing.T) { + regions := map[string]string{"SJC": "San Jose", "SFO": "San Francisco", "OAK": "Oakland"} + + t.Run("unknown codes are dropped", func(t *testing.T) { + got := regionGroupsForRegions([]RegionGroup{ + {Name: "Bay Area", Codes: []string{"SJC", "XXX", "OAK"}}, + }, regions) + assertGroups(t, got, []RegionGroup{{Name: "Bay Area", Codes: []string{"SJC", "OAK"}}}) + }) + + t.Run("a group with no known code is omitted", func(t *testing.T) { + got := regionGroupsForRegions([]RegionGroup{ + {Name: "Nowhere", Codes: []string{"XXX", "YYY"}}, + {Name: "Bay Area", Codes: []string{"SJC"}}, + }, regions) + assertGroups(t, got, []RegionGroup{{Name: "Bay Area", Codes: []string{"SJC"}}}) + }) + + t.Run("codes match case-insensitively and take the served spelling", func(t *testing.T) { + got := regionGroupsForRegions([]RegionGroup{ + {Name: "Bay Area", Codes: []string{"sjc", "oAk"}}, + }, regions) + assertGroups(t, got, []RegionGroup{{Name: "Bay Area", Codes: []string{"SJC", "OAK"}}}) + }) + + t.Run("two spellings of one code collapse", func(t *testing.T) { + got := regionGroupsForRegions([]RegionGroup{ + {Name: "Bay Area", Codes: []string{"sjc", "SJC"}}, + }, regions) + assertGroups(t, got, []RegionGroup{{Name: "Bay Area", Codes: []string{"SJC"}}}) + }) + + t.Run("no regions at all leaves no group", func(t *testing.T) { + got := regionGroupsForRegions([]RegionGroup{{Name: "Bay Area", Codes: []string{"SJC"}}}, map[string]string{}) + if len(got) != 0 { + t.Errorf("got %v, want no groups", groupNames(got)) + } + if got == nil { + t.Error("must return an empty slice, not nil, so the JSON is [] and the client can call .forEach") + } + }) + + t.Run("absent config returns an empty slice", func(t *testing.T) { + got := regionGroupsForRegions(nil, regions) + if got == nil || len(got) != 0 { + t.Errorf("got %v, want an empty non-nil slice", got) + } + }) +} + +// --- the endpoint --- + +func TestHandleConfigRegionGroupsEndpoint(t *testing.T) { + srv, router := setupTestServer(t) + + get := func(t *testing.T) []RegionGroup { + t.Helper() + req := httptest.NewRequest("GET", "/api/config/regions/groups", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d, want 200", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type %q, want JSON", ct) + } + if body := strings.TrimSpace(w.Body.String()); strings.HasPrefix(body, "null") { + t.Fatalf("body is null, want a JSON array: %s", body) + } + var out []RegionGroup + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %s: %v", w.Body.String(), err) + } + return out + } + + t.Run("no groups configured serves []", func(t *testing.T) { + srv.cfg.RegionGroups = nil + if got := get(t); len(got) != 0 { + t.Errorf("got %v, want none", groupNames(got)) + } + }) + + t.Run("configured groups are filtered to served codes", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/config/regions", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + var served map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &served); err != nil { + t.Fatalf("decode regions: %v", err) + } + if len(served) == 0 { + t.Skip("fixture serves no region codes") + } + var one string + for code := range served { + if one == "" || code < one { + one = code + } + } + srv.cfg.RegionGroups = []RegionGroup{ + {Name: "Known", Codes: []string{strings.ToLower(one), "ZZZ"}}, + {Name: "Unknown only", Codes: []string{"ZZZ"}}, + } + assertGroups(t, get(t), []RegionGroup{{Name: "Known", Codes: []string{one}}}) + }) + + t.Run("the regions endpoint stays a flat code to label map", func(t *testing.T) { + srv.cfg.RegionGroups = []RegionGroup{{Name: "Known", Codes: []string{"SJC"}}} + req := httptest.NewRequest("GET", "/api/config/regions", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + var served map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &served); err != nil { + t.Fatalf("/api/config/regions must stay a flat map: %v (%s)", err, w.Body.String()) + } + for _, key := range []string{"groups", "regionGroups", "regions"} { + if _, ok := served[key]; ok { + t.Errorf("/api/config/regions grew a %q key: the documented contract is code → label", key) + } + } + }) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 9f401a8eb..b44ad8b89 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -313,6 +313,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/config/cache", s.handleConfigCache).Methods("GET") r.HandleFunc("/api/config/client", s.handleConfigClient).Methods("GET") r.HandleFunc("/api/config/regions", s.handleConfigRegions).Methods("GET") + r.HandleFunc("/api/config/regions/groups", s.handleConfigRegionGroups).Methods("GET") r.HandleFunc("/api/regions/observed", s.handleRegionsObserved).Methods("GET") r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET") r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET") @@ -771,7 +772,10 @@ func (s *Server) handleGPSSanity(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } -func (s *Server) handleConfigRegions(w http.ResponseWriter, r *http.Request) { +// effectiveRegions is the region code → label map the frontend filters by: +// the configured `regions` entries plus every observer IATA in the database, +// which stands in for its own label when the operator named none. +func (s *Server) effectiveRegions() map[string]string { regions := make(map[string]string) for k, v := range s.cfg.Regions { regions[k] = v @@ -782,7 +786,22 @@ func (s *Server) handleConfigRegions(w http.ResponseWriter, r *http.Request) { regions[c] = c } } - writeJSON(w, regions) + return regions +} + +func (s *Server) handleConfigRegions(w http.ResponseWriter, r *http.Request) { + writeJSON(w, s.effectiveRegions()) +} + +// handleConfigRegionGroups serves the optional named quick-pick groups (#416) +// as a sibling of /api/config/regions rather than a field inside it: that +// endpoint's flat code → label object is a documented contract +// (docs/api-spec.md, proto/config.proto RegionsResponse) and every existing +// consumer reads it as one, so the groups get their own path and the map +// stays untouched. Serves [] when the operator configured none, which is +// what the region filter needs to render exactly as before. +func (s *Server) handleConfigRegionGroups(w http.ResponseWriter, r *http.Request) { + writeJSON(w, regionGroupsForRegions(s.cfg.RegionGroups, s.effectiveRegions())) } func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 1d685ab96..a26c31fd3 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -811,6 +811,7 @@ func TestAllEndpointsReturn200(t *testing.T) { {"/api/config/cache", http.StatusOK}, {"/api/config/client", http.StatusOK}, {"/api/config/regions", http.StatusOK}, + {"/api/config/regions/groups", http.StatusOK}, {"/api/config/theme", http.StatusOK}, {"/api/config/map", http.StatusOK}, {"/api/packets?limit=5", http.StatusOK}, diff --git a/config.example.json b/config.example.json index 1baa24b4b..9bcafa97f 100644 --- a/config.example.json +++ b/config.example.json @@ -325,6 +325,11 @@ "OAK": "Oakland, US", "MRY": "Monterey, US" }, + "_comment_regionGroups": "Optional (#416). Named quick picks over the region codes above: each group renders as one button next to the region filter that selects exactly those codes in one click. Omit the key, or leave it [], and the region filter behaves exactly as it did before the option existed. These are convenience labels over region codes, NOT verified geographic boundaries. Limits: at most 12 groups, at most 24 distinct codes per group, names at most 40 characters. A group that breaks one of those limits, has no name or lists no code is dropped with one log line at startup — a truncated code list or a shortened name would mislabel the filter, so the whole entry is refused instead. Repeated group names (case-insensitive) keep the first one; repeated codes inside a group are de-duplicated. Codes are matched case-insensitively against the served region list (config entries plus every observer IATA in the database), so a code no observer reports is dropped from the group and a group left with no code is not offered at all — an operator can list an observer that is not up yet without creating a dead button. Served by GET /api/config/regions/groups; the flat GET /api/config/regions map is unchanged.", + "regionGroups": [ + { "name": "Bay Area", "codes": ["SFO", "SJC", "OAK"] }, + { "name": "Central Coast", "codes": ["MRY"] } + ], "cacheTTL": { "stats": 10, "nodeDetail": 300, diff --git a/docs/api-spec.md b/docs/api-spec.md index b72c071de..f258ea944 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -56,6 +56,7 @@ - [GET /api/traces/:hash](#get-apitraceshash) - [GET /api/config/theme](#get-apiconfigtheme) - [GET /api/config/regions](#get-apiconfigregions) +- [GET /api/config/regions/groups](#get-apiconfigregionsgroups) - [GET /api/config/areas](#get-apiconfigareas) - [GET /api/config/areas/polygons](#get-apiconfigareaspolygons) - [GET /api/config/client](#get-apiconfigclient) @@ -2752,6 +2753,56 @@ Available regions (IATA codes) merged from config + DB. Returns a flat key-value object. +**Note:** this object is a flat `code → label` map and stays one. Named +quick-pick groups over these codes are served separately by +[GET /api/config/regions/groups](#get-apiconfigregionsgroups). + +--- + +## GET /api/config/regions/groups + +Optional named quick-pick groups over the region codes from +[GET /api/config/regions](#get-apiconfigregions), defined in `config.json` +under `regionGroups` (issue #416). The frontend renders each group as one +button next to the region filter that selects that group's codes in one +action. + +### Response `200` + +```jsonc +[ + { + "name": string, // operator label, e.g. "Bay Area" + "codes": [string] // region codes, e.g. ["SFO", "SJC", "OAK"] + } +] +``` + +Returns `[]` when no groups are configured — the region filter then behaves +exactly as it did before the option existed. + +Group names are convenience labels over region codes, **not** verified +geographic boundaries. + +**Normalization and limits.** Config entries are normalized once at startup +and filtered per request: + +| Case | Behaviour | +| --- | --- | +| `regionGroups` absent or `[]` | Response is `[]`. | +| Name blank, or longer than 40 characters | Group dropped, one startup log line. | +| No codes listed | Group dropped, one startup log line. | +| More than 24 distinct codes in one group | Group dropped, one startup log line. | +| More than 12 groups | Groups past the 12th dropped, one startup log line. | +| Repeated group name (case-insensitive) | First one stands, later ones dropped. | +| Repeated code within a group | De-duplicated, case-insensitively, first spelling kept. | +| Code no observer reports | Dropped from the group; a group with no code left is omitted from the response. | + +A structural violation drops the whole entry rather than serving a +truncated code list or a shortened name, because either would mislabel the +filter the operator asked for. Codes are matched case-insensitively and +returned in the spelling `GET /api/config/regions` uses. + --- ## GET /api/config/areas diff --git a/proto/config.proto b/proto/config.proto index 768dedb77..c1ad99834 100644 --- a/proto/config.proto +++ b/proto/config.proto @@ -73,6 +73,26 @@ message RegionsResponse { map regions = 1; } +// ═══════════════════════════════════════════════════════════════════════════════ +// GET /api/config/regions/groups — Named region quick-pick groups (#416) +// ═══════════════════════════════════════════════════════════════════════════════ + +// One operator-named group of region codes. Convenience labels over the codes +// from GET /api/config/regions, not verified geographic boundaries. +message RegionGroup { + // Operator label, e.g. "Bay Area". Never empty, at most 40 characters. + string name = 1; + // Region codes, in the spelling GET /api/config/regions uses. Never empty, + // de-duplicated, at most 24 entries. + repeated string codes = 2; +} + +// GET /api/config/regions/groups — response. +// A JSON array (at most 12 entries), empty when none is configured. +message RegionGroupsResponse { + repeated RegionGroup groups = 1; +} + // ═══════════════════════════════════════════════════════════════════════════════ // GET /api/config/client — Client-side configuration // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/public/region-filter.js b/public/region-filter.js index fe2d5557e..a50784815 100644 --- a/public/region-filter.js +++ b/public/region-filter.js @@ -4,6 +4,7 @@ (function () { var LS_KEY = 'meshcore-region-filter'; var _regions = {}; // { code: label } + var _groups = []; // #416: [{ name, codes[] }] quick picks, [] = none var _selected = null; // Set of selected region codes, null = all var _listeners = []; var _container = null; @@ -27,9 +28,17 @@ _selected = loadFromStorage(); - /** Fetch regions from server */ + /** Fetch regions from server. + * #416: the optional quick-pick groups come from their own endpoint, + * asked for in parallel so the groups cost no extra round trip, and with + * its own failure path — a deployment whose server predates the endpoint, + * or an operator who configured no groups, must get exactly the control + * this component rendered before. */ async function fetchRegions() { if (_loaded) return _regions; + var groupsReq = fetch('/api/config/regions/groups') + .then(function (r) { return r.json(); }) + .catch(function () { return []; }); try { var data = await fetch('/api/config/regions').then(function (r) { return r.json(); }); _regions = data || {}; @@ -45,9 +54,108 @@ } catch (e) { _regions = {}; } + // No try/catch here on purpose: a failed request, a non-JSON body and a + // body that is not a list of groups are all turned into [] by the + // request's own catch and by normalizeGroups, so a swallow-everything + // wrapper would only hide a real defect in one of them. + _groups = normalizeGroups(await groupsReq); return _regions; } + /** + * #416: the groups the control will offer, from the server's answer. + * + * The server already drops malformed entries and codes no observer + * reports, so this is a guard rather than a second implementation of the + * rules: a response that is not a list of {name, codes[]} is ignored, and + * every code is matched against the region list this component just + * loaded. That last step is what makes the two parallel responses safe to + * combine — a code can disappear between them (the region list is partly + * the set of observer IATAs in the database), and a quick pick for a code + * no pill offers would be a filter the user cannot undo by clicking. + * + * Codes take the spelling the region list uses, so a group's selection + * compares equal to one assembled by clicking pills. A group left with no + * code is dropped, not rendered as a button that does nothing. + */ + function normalizeGroups(list) { + if (!Array.isArray(list)) return []; + var byUpper = {}; + Object.keys(_regions).forEach(function (c) { byUpper[String(c).toUpperCase()] = c; }); + var out = []; + list.forEach(function (g) { + if (!g || typeof g.name !== 'string' || !Array.isArray(g.codes)) return; + var seen = {}; + var codes = []; + g.codes.forEach(function (c) { + var known = byUpper[String(c).trim().toUpperCase()]; + if (!known || seen[known]) return; + seen[known] = true; + codes.push(known); + }); + if (codes.length > 0) out.push({ name: g.name, codes: codes }); + }); + return out; + } + + /** #416: the groups on offer, as a copy callers cannot mutate. */ + function getGroups() { + return _groups.map(function (g) { return { name: g.name, codes: g.codes.slice() }; }); + } + + /** Two selections (Set or null for all regions) hold the same regions. */ + function sameSelection(a, b) { + if (!a || !b) return !a && !b; + if (a.size !== b.size) return false; + var same = true; + a.forEach(function (c) { if (!b.has(c)) same = false; }); + return same; + } + + function notify() { + var sel = getSelected(); + _listeners.forEach(function (fn) { fn(sel); }); + } + + /** + * #416: the one place a new selection is saved, rendered and announced, so + * a quick pick and a click on a pill travel the same path and a page + * refetches once either way. + * + * An unchanged selection re-renders (the dropdown's own checkboxes have + * already toggled themselves, so the markup has to be put back) but saves + * nothing and tells nobody: picking the group already shown, or clearing a + * filter that is already clear, must not make every listening page fetch + * again. Returns whether the selection changed. + */ + function applySelection(next, container) { + if (sameSelection(_selected, next)) { + render(container); + return false; + } + _selected = next; + saveToStorage(); + render(container); + notify(); + return true; + } + + /** #416: what picking `group` selects — its codes, or all regions (null) + * when they are every code there is, the same normalization a pill click + * applies so the two cannot disagree about what "everything" means. */ + function groupSelection(group) { + if (group.codes.length >= Object.keys(_regions).length) return null; + return new Set(group.codes); + } + + /** #416: select the codes of the group at `index`. An index that names no + * group changes nothing. */ + function pickGroup(index, container) { + var group = _groups[index]; + if (!group) return; + applySelection(groupSelection(group), container); + } + /** Get selected regions as array, or null if all */ function getSelected() { if (!_selected || _selected.size === 0) return null; @@ -68,22 +176,22 @@ /** Handle a region toggle (shared logic for both pill and dropdown modes) */ function toggleRegion(region, codes, container) { + var next; if (region === '__all__') { - _selected = null; + next = null; + } else if (!_selected) { + next = new Set([region]); } else { - if (!_selected) { - _selected = new Set([region]); - } else if (_selected.has(region)) { - _selected.delete(region); - if (_selected.size === 0) _selected = null; + next = new Set(_selected); + if (next.has(region)) { + next.delete(region); + if (next.size === 0) next = null; } else { - _selected.add(region); + next.add(region); } - if (_selected && _selected.size === codes.length) _selected = null; + if (next && next.size === codes.length) next = null; } - saveToStorage(); - render(container); - _listeners.forEach(function (fn) { fn(getSelected()); }); + applySelection(next, container); } // #333: region codes include observer IATA values from MQTT, so every code @@ -99,8 +207,8 @@ return sel.length + ' Regions'; } - /** Render pill bar mode (≤4 regions) */ - function renderPills(container, codes) { + /** Pill bar mode markup (≤4 regions) */ + function pillsHtml(codes) { var allSelected = !_selected; var html = '
'; html += 'Region:'; @@ -113,17 +221,11 @@ '" data-region="' + escapeHtml(code) + '" role="checkbox" aria-checked="' + !!active + '">' + escapeHtml(label) + ''; }); html += '
'; - container.innerHTML = html; - - container.onclick = function (e) { - var btn = e.target.closest('[data-region]'); - if (!btn) return; - toggleRegion(btn.dataset.region, codes, container); - }; + return html; } - /** Render dropdown mode (>4 regions) */ - function renderDropdown(container, codes) { + /** Dropdown mode markup (>4 regions) */ + function dropdownHtml(codes) { var allSelected = !_selected; var html = '
'; html += '
'; - container.innerHTML = html; + return html; + } + /** Wire the dropdown control after its markup is in the container. */ + function wireDropdown(container, codes) { var trigger = container.querySelector('.region-dropdown-trigger'); var menu = container.querySelector('.region-dropdown-menu'); @@ -170,6 +275,49 @@ }; } + /** #416: quick-pick row markup, empty when no group is on offer. The codes + * go in the title so the label can stay short without hiding what it + * selects; both slots are escaped (#333) because a group name is operator + * text and the codes are observer IATA values from MQTT. */ + function quickPicksHtml() { + if (_groups.length === 0) return ''; + var html = '
'; + _groups.forEach(function (g, i) { + var active = sameSelection(_selected, groupSelection(g)); + html += ''; + }); + return html + '
'; + } + + /** #416: one delegated handler for the whole container, so the quick picks + * work in both modes without a second listener. In dropdown mode + * [data-region] belongs to the menu's own change handler — handling the + * bubbled click here too would toggle the region twice. */ + function wireClicks(container, codes, pillMode) { + container.onclick = function (e) { + var pick = e.target.closest('[data-region-group]'); + var index = pick && pick.dataset ? pick.dataset.regionGroup : null; + if (index != null && /^[0-9]+$/.test(String(index))) { + pickGroup(Number(index), container); + return; + } + if (!pillMode) return; + var btn = e.target.closest('[data-region]'); + if (!btn) return; + toggleRegion(btn.dataset.region, codes, container); + }; + } + + /** #416: the container only wraps onto a second line while it has a + * quick-pick row to wrap (public/style.css). */ + function setQuickPickClass(container, on) { + if (!container.classList) return; + if (on) container.classList.add('has-region-quick-picks'); + else container.classList.remove('has-region-quick-picks'); + } + /** Render the filter bar into a container element */ function render(container) { // Clean up previous outside-click listener if any @@ -179,15 +327,16 @@ if (codes.length < 2) { container.innerHTML = ''; container.style.display = 'none'; + setQuickPickClass(container, false); return; } container.style.display = ''; - if (codes.length > 4 || container._forceDropdown) { - renderDropdown(container, codes); - } else { - renderPills(container, codes); - } + var dropdown = codes.length > 4 || container._forceDropdown; + container.innerHTML = (dropdown ? dropdownHtml(codes) : pillsHtml(codes)) + quickPicksHtml(); + if (dropdown) wireDropdown(container, codes); + wireClicks(container, codes, !dropdown); + setQuickPickClass(container, _groups.length > 0); } /** Subscribe to selection changes. Callback receives selected array or null */ @@ -271,7 +420,8 @@ onChange: onChange, offChange: offChange, fetchRegions: fetchRegions, - setSelected: setSelected + setSelected: setSelected, + getGroups: getGroups }; window.RegionShowAll = { get: showAllGet, diff --git a/public/style.css b/public/style.css index 22c783851..09b1375f4 100644 --- a/public/style.css +++ b/public/style.css @@ -2572,6 +2572,9 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } /* Region filter shows just its compact pills/dropdown trigger in the header. */ .ch-header-region .region-filter-bar { padding: 0; gap: 4px; } .ch-header-region .region-pill { padding: 2px 6px; font-size: 11px; } + /* #416 — the channels header is the tightest host of the region control. */ + .ch-header-region .region-quick-pick { padding: 2px 6px; font-size: 11px; } + .ch-header-region .region-quick-picks { margin-left: 6px; padding-left: 6px; gap: 4px; } .ch-header-region .region-dropdown-trigger { padding: 3px 6px; font-size: 11px; } /* #239: order: 1 puts the dropdown trigger (more than 4 regions) at the right edge of the strip, so its menu opens leftwards from there. */ @@ -3712,6 +3715,46 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); } font-size: 12px; font-weight: 600; color: var(--text-muted); align-self: center; margin-right: 2px; user-select: none; white-space: nowrap; flex-shrink: 0; } + +/* #416 — named region quick picks. One wrapped row of buttons next to the + region control, separated by a hairline so it reads as a shortcut over the + same codes rather than more region pills. Rendered only when the operator + configured `regionGroups`, which is also what lets the container wrap: + without groups the layout is byte-for-byte what it was. */ +.region-filter-container.has-region-quick-picks { flex-wrap: wrap; row-gap: 4px; } +.region-quick-picks { + display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; + margin-left: 8px; padding-left: 8px; border-left: 1px solid var(--border); +} +.region-quick-pick { + display: inline-flex; align-items: center; justify-content: center; + padding: 4px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; + font-family: var(--font); cursor: pointer; border: 1.5px dashed var(--border); + background: transparent; color: var(--text-muted); transition: all 0.15s; + white-space: nowrap; flex-shrink: 0; max-width: 14em; + overflow: hidden; text-overflow: ellipsis; +} +.region-quick-pick:hover { border-color: var(--accent); color: var(--accent); } +.region-quick-pick:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* Same --accent-strong / --text-on-accent pair as .region-pill-active, kept + for the 4.83:1 AA contrast #1668 M5 settled on; the dashed border is what + tells a group apart from a single region at a glance. */ +.region-quick-pick-active { + background: var(--accent-strong); color: var(--text-on-accent); + border-style: solid; border-color: var(--accent-strong); +} +.region-quick-pick-active:hover { opacity: 0.85; color: var(--text-on-accent); } +/* On a phone (390 px and down the row wraps under the control) the hairline + would sit above nothing useful, so the quick picks become their own + full-width row instead of an indented continuation. */ +@media (max-width: 480px) { + .region-quick-picks { + margin-left: 0; padding-left: 0; border-left: 0; + flex-basis: 100%; width: 100%; + } + .region-quick-pick { max-width: 100%; } +} + .analytics-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0; } /* The one time-window / metric