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 @@