From 1418a566fdccda9f48c7faf6cdbdcf995628ad97 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:10:52 +0200 Subject: [PATCH] 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 --- API Server/internal/api/me.go | 54 ++++++++++--- API Server/internal/bootstrap/schema.go | 3 + API Server/internal/models/models.go | 3 +- Web App/web/src/i18n/da.json | 1 + Web App/web/src/i18n/en.json | 1 + Web App/web/src/i18n/pl.json | 1 + Web App/web/src/prefs.js | 5 ++ Web App/web/src/views/Charging.vue | 101 ++++++++++++++++++++++-- 8 files changed, 150 insertions(+), 19 deletions(-) diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go index 6bcb45c..d2eb6d2 100644 --- a/API Server/internal/api/me.go +++ b/API Server/internal/api/me.go @@ -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 { diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index 86f03ab..8112b0d 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -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), }, } diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 461e3eb..de36eb9 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -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"` diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 6df93ed..39fc332 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -48,6 +48,7 @@ "eyebrow": "Opladning og kort", "title": "Ladere i nærheden", "tabs": { + "dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.", "public": "Offentlige ladere", "home": "Hjemmeladere" }, diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 4e39f50..0d1e796 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -48,6 +48,7 @@ "eyebrow": "Charging & map", "title": "Nearby chargers", "tabs": { + "dragHint": "Drag a tab to rearrange the charging tabs.", "public": "Public chargers", "home": "Home chargers" }, diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 06c6263..4bea26a 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -48,6 +48,7 @@ "eyebrow": "Ładowanie i mapa", "title": "Ładowarki w pobliżu", "tabs": { + "dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.", "public": "Ładowarki publiczne", "home": "Ładowarki domowe" }, diff --git a/Web App/web/src/prefs.js b/Web App/web/src/prefs.js index ce0fc3e..2c4934d 100644 --- a/Web App/web/src/prefs.js +++ b/Web App/web/src/prefs.js @@ -12,6 +12,10 @@ export const prefs = reactive({ // rows, the provider's readings. Nothing to apply to the document — the views // read it to decide whether their elements are draggable at all. dragLocked: false, + // The charging page's tab arrangement. Kept here rather than fetched by the + // view because it arrives with the profile anyway, and the bar has to render + // in the right order on the first paint. + chargerTabOrder: [], }); const FONT_SCALE = { small: "93.75%", medium: "100%", large: "112.5%" }; @@ -49,6 +53,7 @@ export function applyProfilePrefs(profile) { prefs.currency = profile.currency || "USD"; prefs.fontSize = profile.fontSize || "medium"; prefs.dragLocked = !!profile.dragLocked; + prefs.chargerTabOrder = Array.isArray(profile.chargerTabOrder) ? profile.chargerTabOrder : []; applyTheme(); applyFontSize(); } diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 8de96e9..73e47fd 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -1,6 +1,7 @@