A page opens where you put its tabs

Every tab bar in the app drags into the order you want, and then all three of
them opened on a tab picked in the source anyway: "public" on Charging, "info"
on a car, "personal" in Settings. Dragging Home chargers to the front of the
charging bar rearranged the bar and changed nothing about where the page landed,
which is the opposite of what dragging it there says.

So the front of the bar is now the landing tab, everywhere. An arrangement is
already the statement of what you want to see first; it just wasn't being read
as one. Settings > Appearance overrides it per page for the case where reading
order and landing tab are two different wishes, with "First in the bar" as the
default and the meaning of no override at all.

The rule lives in one place, lib/tabs.js, because it is one rule and three
pages: the saved choice if that tab is actually on the bar, otherwise whatever
leads it. The bar it is given is the one that will really render, hidden tabs
and inapplicable ones already dropped, so a default that no longer has a button
- a tab switched off for that car, Users on a non-admin - falls back to the
front instead of opening nothing. The tab key lists moved there too, since the
picker needs all three and would otherwise have copied them.

Each page starts on no tab and keeps following the profile until the user says
otherwise, rather than guessing and then correcting itself: the arrangement and
the default both arrive with /api/me, which on a hard refresh lands after the
view has mounted. A click ends the following, and so does the start of a drag -
rearranging a bar must not pull the content out from under the pointer. In
Settings ?tab= still wins over both, since that is what /admin redirects to.

Stored as defaultTabs on the profile, one page->tab map validated per page: a
tab that exists but on another page is an error, and an empty value is stored
as an absent key so "no default" has a single representation.

Also adds charger_tab_order and charger_card_order to the PocketBase setup
script. They were never there - the arrangements of the last two commits had no
column to persist into on a freshly set-up server - and default_tabs would have
gone the same way beside them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-02 08:52:20 +02:00
co-authored by Claude Opus 5
parent 641f427db4
commit f3235c403c
13 changed files with 347 additions and 33 deletions
@@ -0,0 +1,53 @@
package api
import "testing"
// Which tab a page opens on is stored per user as a page->tab map, and every
// tabbed page in the web app is in it. The map is small, but it is the one
// preference whose keys have to line up with three different bars, so the
// validation is worth pinning down: an unknown page or an unknown tab is a stale
// or wrong client, and an empty value is how a client says "no default, open on
// whichever tab leads the bar".
func TestNormalizeDefaultTabs(t *testing.T) {
got, err := normalizeDefaultTabs(map[string]string{
"charging": " home ",
"car": "reminders",
"settings": "",
})
if err != nil {
t.Fatalf("normalizeDefaultTabs: %v", err)
}
if got["charging"] != "home" {
t.Errorf("charging = %q, want the trimmed \"home\"", got["charging"])
}
if got["car"] != "reminders" {
t.Errorf("car = %q, want \"reminders\"", got["car"])
}
// Cleared rather than stored empty, so "no default" has one representation.
if _, ok := got["settings"]; ok {
t.Errorf("settings = %q, want the key dropped", got["settings"])
}
if empty, err := normalizeDefaultTabs(nil); err != nil || len(empty) != 0 {
t.Errorf("normalizeDefaultTabs(nil) = %v, %v; want empty and no error", empty, err)
}
if _, err := normalizeDefaultTabs(map[string]string{"garage": "info"}); err == nil {
t.Error("normalizeDefaultTabs accepted a page that has no tabs, want an error")
}
// The tab exists, but on another page: the sets are validated per page.
if _, err := normalizeDefaultTabs(map[string]string{"charging": "reminders"}); err == nil {
t.Error("normalizeDefaultTabs accepted a tab from another page, want an error")
}
// The tab sets are the contract the web app's src/lib/tabs.js mirrors.
for page, want := range map[string]int{"charging": 2, "car": 10, "settings": 4} {
if len(validDefaultTabs[page]) != want {
t.Errorf("%s has %d tabs, want %d", page, len(validDefaultTabs[page]), want)
}
}
if len(validDefaultTabs) != 3 {
t.Errorf("validDefaultTabs has %d pages, want the 3 tabbed pages", len(validDefaultTabs))
}
}
+68
View File
@@ -45,6 +45,9 @@ type userRecord struct {
// reason.
ChargerTabOrder json.RawMessage `json:"charger_tab_order"`
ChargerCardOrder json.RawMessage `json:"charger_card_order"`
// Which tab each tabbed page opens on. Raw for the same reason as the
// arrangements: never-set records come back as null or "".
DefaultTabs json.RawMessage `json:"default_tabs"`
}
// carOrder decodes the stored garage arrangement, treating anything unexpected
@@ -55,6 +58,8 @@ func (rec userRecord) chargerTabOrder() []string { return decodeStringList(rec.C
func (rec userRecord) chargerCardOrder() []string { return decodeStringList(rec.ChargerCardOrder) }
func (rec userRecord) defaultTabs() map[string]string { return decodeStringMap(rec.DefaultTabs) }
// decodeStringList reads a PocketBase json field that holds a list of strings,
// treating anything unexpected as empty rather than failing the whole read.
func decodeStringList(raw json.RawMessage) []string {
@@ -68,6 +73,19 @@ func decodeStringList(raw json.RawMessage) []string {
return out
}
// decodeStringMap is decodeStringList for a json field holding a flat
// string->string map, and is just as forgiving about what it finds.
func decodeStringMap(raw json.RawMessage) map[string]string {
if len(raw) == 0 {
return nil
}
var out map[string]string
if err := json.Unmarshal(raw, &out); err != nil {
return nil
}
return out
}
func (rec userRecord) toModel() models.User {
u := models.User{
ID: rec.ID,
@@ -89,6 +107,7 @@ func (rec userRecord) toModel() models.User {
CarOrder: rec.carOrder(),
ChargerTabOrder: rec.chargerTabOrder(),
ChargerCardOrder: rec.chargerCardOrder(),
DefaultTabs: rec.defaultTabs(),
}
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
u.DeletionRequestedAt = &t
@@ -148,6 +167,10 @@ type updateMeRequest struct {
CarOrder *[]string `json:"carOrder"`
ChargerTabOrder *[]string `json:"chargerTabOrder"`
ChargerCardOrder *[]string `json:"chargerCardOrder"`
// The whole map every time — the clients send it back with one page changed,
// so a partial merge here would only make "no default" impossible to express.
DefaultTabs *map[string]string `json:"defaultTabs"`
}
// maxCarOrder bounds the stored arrangement. listCars fetches at most 200 owned
@@ -177,6 +200,43 @@ func normalizeChargerCardOrder(in []string) ([]string, error) {
return normalizeOrder("chargerCardOrder", in, maxChargerTabOrder)
}
// The tabbed pages a default tab can be set for, and the tabs each one has.
// Kept in step with the web app's src/lib/tabs.js. A key the page does not have
// would simply never match a button and the page would open on the front of its
// bar anyway — this is here to keep junk off the record, not to protect the
// clients from themselves.
var validDefaultTabs = map[string]map[string]bool{
"charging": {"public": true, "home": true},
"car": {
"provider": true, "info": true, "services": true, "technical": true,
"maintenance": true, "fuel": true, "charging": true, "documents": true,
"parts": true, "reminders": true,
},
"settings": {"personal": true, "users": true, "organization": true, "integrations": true},
}
// normalizeDefaultTabs cleans a client-supplied default-tab map. An empty value
// is how a client says "no default, open on the front of the bar", and is stored
// as an absent key rather than an empty string so the two cannot drift apart.
func normalizeDefaultTabs(in map[string]string) (map[string]string, error) {
out := make(map[string]string, len(in))
for page, tab := range in {
tabs, ok := validDefaultTabs[page]
if !ok {
return nil, fmt.Errorf("defaultTabs: %q is not a tabbed page", page)
}
tab = strings.TrimSpace(tab)
if tab == "" {
continue
}
if !tabs[tab] {
return nil, fmt.Errorf("defaultTabs: %q is not a tab on the %s page", tab, page)
}
out[page] = tab
}
return out, nil
}
func normalizeOrder(field string, in []string, max int) ([]string, error) {
if len(in) > max {
return nil, fmt.Errorf("%s is too long (max %d)", field, max)
@@ -301,6 +361,14 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
payload["charger_card_order"] = keys
}
if in.DefaultTabs != nil {
tabs, err := normalizeDefaultTabs(*in.DefaultTabs)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["default_tabs"] = tabs
}
var rec userRecord
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {