The charging tabs drag, like a car's do

Reordering the bar meant editing the template, which is a poor way to ask for
Home chargers first. The tabs now drag into either order on the same native
drag events as a car's tabs and the garage, down to the live reorder as the
pointer crosses a tab, the grab cursor, and the rail's lock holding the bar
still for anyone who would rather not nudge it on the way to a tab.

The arrangement is saved on the profile as charger_tab_order, beside the garage
order and for the same reason: it is a layout choice that should follow the
account rather than the browser, unlike which cards are folded. The field is
reconciled onto the users collection at boot, so no migration step. Its
normalizer is the garage's, which now takes the field name and cap as
arguments instead of being copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 21:10:52 +02:00
co-authored by Claude Opus 5
parent 2a5d21d328
commit 1418a566fd
8 changed files with 150 additions and 19 deletions
+41 -13
View File
@@ -41,12 +41,17 @@ type userRecord struct {
// holds — null on a record that has never been arranged, and "" on one
// PocketBase stored as an empty value — neither of which is a []string.
CarOrder json.RawMessage `json:"car_order"`
// The charging page's tab arrangement, stored the same way and for the same
// reason.
ChargerTabOrder json.RawMessage `json:"charger_tab_order"`
}
// carOrder decodes the stored garage arrangement, treating anything unexpected
// as "not arranged yet" rather than failing the whole profile read.
func (rec userRecord) carOrder() []string { return decodeStringList(rec.CarOrder) }
func (rec userRecord) chargerTabOrder() []string { return decodeStringList(rec.ChargerTabOrder) }
// 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 {
@@ -77,8 +82,9 @@ func (rec userRecord) toModel() models.User {
Role: orDefault(rec.Role, "user"),
Created: rec.Created,
Organization: rec.Organization,
CarOrder: rec.carOrder(),
Organization: rec.Organization,
CarOrder: rec.carOrder(),
ChargerTabOrder: rec.chargerTabOrder(),
}
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
u.DeletionRequestedAt = &t
@@ -127,15 +133,16 @@ func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
}
type updateMeRequest struct {
Name *string `json:"name"`
Bio *string `json:"bio"`
Theme *string `json:"theme"`
Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"`
Currency *string `json:"currency"`
FontSize *string `json:"fontSize"`
DragLocked *bool `json:"dragLocked"`
CarOrder *[]string `json:"carOrder"`
Name *string `json:"name"`
Bio *string `json:"bio"`
Theme *string `json:"theme"`
Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"`
Currency *string `json:"currency"`
FontSize *string `json:"fontSize"`
DragLocked *bool `json:"dragLocked"`
CarOrder *[]string `json:"carOrder"`
ChargerTabOrder *[]string `json:"chargerTabOrder"`
}
// maxCarOrder bounds the stored arrangement. listCars fetches at most 200 owned
@@ -149,8 +156,21 @@ const maxCarOrder = 500
// a car the user no longer has is harmless: listCars ignores what it can't
// match, and the next drag rewrites the list anyway.
func normalizeCarOrder(in []string) ([]string, error) {
if len(in) > maxCarOrder {
return nil, fmt.Errorf("carOrder is too long (max %d)", maxCarOrder)
return normalizeOrder("carOrder", in, maxCarOrder)
}
// maxChargerTabOrder bounds the charging page's tab arrangement. The page has
// two tabs; the room is for tabs a later release adds, not for a client to park
// a blob on the record.
const maxChargerTabOrder = 20
func normalizeChargerTabOrder(in []string) ([]string, error) {
return normalizeOrder("chargerTabOrder", in, maxChargerTabOrder)
}
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)
}
out := make([]string, 0, len(in))
seen := make(map[string]bool, len(in))
@@ -256,6 +276,14 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
payload["car_order"] = ids
}
if in.ChargerTabOrder != nil {
keys, err := normalizeChargerTabOrder(*in.ChargerTabOrder)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["charger_tab_order"] = keys
}
var rec userRecord
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {
+3
View File
@@ -252,6 +252,9 @@ var collectionsSchema = map[string][]fieldDef{
// user rather than per car, so it also covers cars shared with them and
// never reorders somebody else's garage.
fJSON("car_order", 20000),
// The charging page's tab order. Per user like the garage order, and for
// the same reason: it is this person's arrangement of their own page.
fJSON("charger_tab_order", 2000),
},
}
+2 -1
View File
@@ -495,7 +495,8 @@ type User struct {
// CarOrder is the garage arrangement: car ids in the order this user dragged
// them into. The car list is already returned in this order, so a client only
// needs it to send an updated arrangement back.
CarOrder []string `json:"carOrder"`
CarOrder []string `json:"carOrder"`
ChargerTabOrder []string `json:"chargerTabOrder"`
// Non-empty while an account-deletion request is pending its cooldown.
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`