feat: Refine trail category model (#1059)

* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
slothful-vassal
2026-06-29 03:16:26 +02:00
committed by GitHub
parent 50cb6c7f5f
commit 3b8f00fd26
116 changed files with 9363 additions and 453 deletions

View File

@@ -204,10 +204,24 @@ Manifest `configSchema` defines plugin-owned settings that are passed to plugin
| `merge.available` | Controls whether the UI offers auto-merge for this plugin. Defaults to `true`. |
| `merge.enabled` | Runs auto-merge after trail import. |
| `createSummitLogForCompleted` | Creates summit logs for completed imports. |
| `categoryMapping` | Maps `metadata.providerCategory` to local category IDs or names. |
| `categoryMapping` | Maps `metadata.providerCategory` to local category or subcategory targets. |
| `connectors` | Provides host-owned base URL, TLS, private-network, and storage redirect settings for configured connectors. |
The settings UI lets users edit `categoryMapping` per plugin instance for trail import plugins.
Mapping values can be strings for broad category-only compatibility, or objects
with `category` and optional `subcategory`, using local record IDs or canonical
names:
```json
{
"categoryMapping": {
"Ride": { "category": "Biking", "subcategory": "Road" },
"GravelRide": { "category": "Biking", "subcategory": "Gravel" },
"Hike": "Hiking"
}
}
```
Plugins may describe provider-owned category values for the settings UI with
`metadata.providerCategories`. This is display-only metadata; `categoryMapping`
keys still use the raw provider category values emitted as

View File

@@ -101,31 +101,31 @@
"hostConfig": {
"categoryMapping": {
"hike": "Hiking",
"mountaineering": "Hiking",
"racebike": "Biking",
"e_racebike": "Biking",
"touringbicycle": "Biking",
"e_touringbicycle": "Biking",
"mtb": "Biking",
"e_mtb": "Biking",
"mtb_easy": "Biking",
"e_mtb_easy": "Biking",
"mtb_advanced": "Biking",
"e_mtb_advanced": "Biking",
"downhillbike": "Biking",
"mountaineering": { "category": "Hiking", "subcategory": "Alpine" },
"racebike": { "category": "Biking", "subcategory": "Road" },
"e_racebike": { "category": "Biking", "subcategory": "E-Bike" },
"touringbicycle": { "category": "Biking", "subcategory": "Touring" },
"e_touringbicycle": { "category": "Biking", "subcategory": "Touring" },
"mtb": { "category": "Biking", "subcategory": "MTB" },
"e_mtb": { "category": "Biking", "subcategory": "MTB" },
"mtb_easy": { "category": "Biking", "subcategory": "MTB" },
"e_mtb_easy": { "category": "Biking", "subcategory": "MTB" },
"mtb_advanced": { "category": "Biking", "subcategory": "MTB" },
"e_mtb_advanced": { "category": "Biking", "subcategory": "MTB" },
"downhillbike": { "category": "Biking", "subcategory": "MTB" },
"unicycle": "Biking",
"citybike": "Biking",
"jogging": "Walking",
"citybike": { "category": "Biking", "subcategory": "Touring" },
"jogging": { "category": "Running", "subcategory": "Road" },
"nordicwalking": "Walking",
"skaten": "Walking",
"other": "Walking",
"climbing": "Climbing",
"nordic": "Skiing",
"nordic": { "category": "Skiing", "subcategory": "Cross-country" },
"skialpin": "Skiing",
"skitour": "Skiing",
"skitour": { "category": "Skiing", "subcategory": "Backcountry" },
"sled": "Skiing",
"snowboard": "Skiing",
"snowshoe": "Skiing"
"snowshoe": { "category": "Hiking", "subcategory": "Snowshoeing" }
}
},
"metadata": {

View File

@@ -412,7 +412,17 @@
"type": "boolean"
},
"categoryMapping": {
"$ref": "#/definitions/stringMap"
"type": "object",
"additionalProperties": {
"oneOf": [
{
"type": "string"
},
{
"$ref": "#/definitions/categoryMappingTarget"
}
]
}
},
"connectors": {
"type": "object",
@@ -476,6 +486,18 @@
"$ref": "#/definitions/stringMap"
}
}
},
"categoryMappingTarget": {
"type": "object",
"additionalProperties": false,
"properties": {
"category": {
"type": "string"
},
"subcategory": {
"type": "string"
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
package main
func activityTypeFromProvider(value string) string {
switch value {
case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard":
return "skiing"
case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf":
return "canoeing"
case "Hike", "Snowshoe":
return "hiking"
case "Run", "TrailRun", "VirtualRun":
return "running"
case "Walk", "Golf", "Skateboard", "Wheelchair":
return "walking"
case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide":
return "biking"
case "RockClimbing":
return "climbing"
default:
return value
}
}

View File

@@ -208,21 +208,5 @@ func providerActivityType(activity *detailedActivity) string {
}
func activityType(activity *detailedActivity) string {
value := providerActivityType(activity)
switch value {
case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard":
return "skiing"
case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf":
return "canoeing"
case "Hike", "Snowshoe":
return "hiking"
case "Run", "VirtualRun", "Walk", "Golf", "Skateboard", "Wheelchair":
return "walking"
case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide":
return "biking"
case "RockClimbing":
return "climbing"
default:
return value
}
return activityTypeFromProvider(providerActivityType(activity))
}

View File

@@ -0,0 +1,18 @@
package main
import "testing"
func TestActivityTypeMapsRunsToRunning(t *testing.T) {
cases := map[string]string{
"Run": "running",
"TrailRun": "running",
"VirtualRun": "running",
"Walk": "walking",
}
for providerType, want := range cases {
if got := activityTypeFromProvider(providerType); got != want {
t.Fatalf("activityTypeFromProvider(%q) = %q, want %q", providerType, got, want)
}
}
}

View File

@@ -145,15 +145,15 @@
"route:1": "Biking",
"route:2": "Walking",
"AlpineSki": "Skiing",
"BackcountrySki": "Skiing",
"BackcountrySki": { "category": "Skiing", "subcategory": "Backcountry" },
"Badminton": "Other",
"Canoeing": "Canoeing",
"Crossfit": "Workout",
"EBikeRide": "Biking",
"EMountainBikeRide": "Biking",
"Elliptical": "Workout",
"Crossfit": "Other",
"EBikeRide": { "category": "Biking", "subcategory": "E-Bike" },
"EMountainBikeRide": { "category": "Biking", "subcategory": "E-Bike" },
"Elliptical": "Other",
"Golf": "Other",
"GravelRide": "Biking",
"GravelRide": { "category": "Biking", "subcategory": "Gravel" },
"Handcycle": "Biking",
"HighIntensityIntervalTraining": "Other",
"Hike": "Hiking",
@@ -161,40 +161,40 @@
"InlineSkate": "Walking",
"Kayaking": "Canoeing",
"Kitesurf": "Canoeing",
"MountainBikeRide": "Biking",
"NordicSki": "Skiing",
"MountainBikeRide": { "category": "Biking", "subcategory": "MTB" },
"NordicSki": { "category": "Skiing", "subcategory": "Cross-country" },
"Pickleball": "Other",
"Pilates": "Other",
"Racquetball": "Other",
"Ride": "Biking",
"Ride": { "category": "Biking", "subcategory": "Road" },
"RockClimbing": "Climbing",
"RollerSki": "Skiing",
"RollerSki": { "category": "Skiing", "subcategory": "Cross-country" },
"Rowing": "Canoeing",
"Run": "Walking",
"Run": { "category": "Running", "subcategory": "Road" },
"Sail": "Canoeing",
"Skateboard": "Walking",
"Snowboard": "Skiing",
"Snowshoe": "Hiking",
"Snowshoe": { "category": "Hiking", "subcategory": "Snowshoeing" },
"Soccer": "Other",
"Squash": "Other",
"StairStepper": "Workout",
"StairStepper": "Other",
"StandUpPaddling": "Canoeing",
"Surfing": "Canoeing",
"Swim": "Other",
"TableTennis": "Other",
"Tennis": "Other",
"TrailRun": "Other",
"TrailRun": { "category": "Running", "subcategory": "Trail" },
"Training": "Other",
"Velomobile": "Biking",
"VirtualRide": "Biking",
"VirtualRow": "Other",
"VirtualRun": "Walking",
"VirtualRun": "Running",
"Walk": "Walking",
"WeightTraining": "Workout",
"WeightTraining": "Other",
"Wheelchair": "Walking",
"Windsurf": "Canoeing",
"Workout": "Workout",
"Yoga": "Workout"
"Workout": "Other",
"Yoga": "Other"
}
},
"metadata": {