Add pure helpers for routine ordering, copy naming, and a grouped exercise catalog

Groundwork for the Library tab: RoutineOrdering.changedOrders turns an
.onMove gesture into the minimal set of order writes (normalizing legacy
colliding orders on first drag), RoutineNaming.uniqueName picks the next
free "X Copy" name for duplication, and ExerciseCatalog groups the bundled
exercise library into curated category sections with name/category/target
search. Plus ExerciseDocument.planSummary for read-only document rendering.
All covered by unit tests, including a motion/info name-parity assertion.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
2026-07-15 10:38:43 -04:00
parent ce4c581bd0
commit 36dad21233
6 changed files with 346 additions and 0 deletions
@@ -0,0 +1,84 @@
//
// ExerciseCatalog.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// One group of exercises in the picker, in catalog display order.
struct ExerciseCatalogSection {
let title: String
let names: [String]
}
/// Curated, searchable sections over the bundled exercise library every name from
/// `ExerciseMotionLibrary.exerciseNames`, grouped by `ExerciseInfoLibrary.info(for:)?.category`
/// in a hand-picked category order rather than the raw alphabetical/declaration order.
/// Building the grouping is a `Bundle.main` resource read per exercise, so it's done once
/// as an immutable `static let` (Swift 6 strict concurrency: no shared mutable state).
enum ExerciseCatalog {
/// Bucket for an exercise with no bundled `info.md`, or one whose category isn't in
/// `categoryOrder` always sorts last.
static let otherTitle = "Other"
/// Curated display order for the categories actually authored in the library's
/// `info.md` files (grep `**Category:**` under `Workouts/Resources/ExerciseMotions/`):
/// "Mobility / warm-up", "Warm-up", "Warm-up and main circuit", "Main circuit",
/// "Stretching", "Cardio", "Mindfulness". Warm-up/mobility categories lead (that's
/// how a workout starts), then the main circuit, then stretching/cardio/mindfulness.
/// Any category found in the data but missing here still gets its own section
/// (appended before "Other") rather than silently absorbed into it.
private static let categoryOrder: [String] = [
"Mobility / warm-up",
"Warm-up",
"Warm-up and main circuit",
"Main circuit",
"Stretching",
"Cardio",
"Mindfulness",
]
/// Every bundled exercise, grouped into curated-order sections with alphabetized
/// names within each section.
static let sections: [ExerciseCatalogSection] = {
var namesByCategory: [String: [String]] = [:]
for name in ExerciseMotionLibrary.exerciseNames {
let category = ExerciseInfoLibrary.info(for: name)?.category ?? otherTitle
namesByCategory[category, default: []].append(name)
}
var order = categoryOrder
for category in namesByCategory.keys where category != otherTitle && !order.contains(category) {
order.append(category)
}
order.append(otherTitle)
return order.compactMap { category -> ExerciseCatalogSection? in
guard let names = namesByCategory[category], !names.isEmpty else { return nil }
return ExerciseCatalogSection(title: category, names: names.sorted())
}
}()
/// `sections` filtered to exercises matching `query` (case-insensitive) against the
/// exercise name, its category, or any of its `ExerciseInfo.targets` preserving
/// section structure and dropping sections left with no matches. An empty/whitespace
/// query returns every section unfiltered.
static func sections(matching query: String) -> [ExerciseCatalogSection] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return sections }
return sections.compactMap { section -> ExerciseCatalogSection? in
let matches = section.names.filter { name in
if name.localizedCaseInsensitiveContains(trimmed) { return true }
guard let info = ExerciseInfoLibrary.info(for: name) else { return false }
if let category = info.category, category.localizedCaseInsensitiveContains(trimmed) {
return true
}
return info.targets.contains { $0.localizedCaseInsensitiveContains(trimmed) }
}
return matches.isEmpty ? nil : ExerciseCatalogSection(title: section.title, names: matches)
}
}
}
@@ -0,0 +1,28 @@
//
// ExerciseDocument+PlanSummary.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
extension ExerciseDocument {
/// One-line plan summary for list rows mirrors `Exercise.planSummary(weightUnit:)`
/// (`Shared/Model/Entities.swift`) exactly, but works from the on-disk document
/// rather than the SwiftData cache entity: duration exercises show their hold time
/// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones keep
/// the full "3 × 10 × 40 lb".
func planSummary(weightUnit: WeightUnit) -> String {
switch LoadType(rawValue: loadType) ?? .weight {
case .duration:
let m = durationSeconds / 60, s = durationSeconds % 60
let hold = m > 0 && s > 0 ? "\(m)m \(s)s" : (m > 0 ? "\(m) min" : "\(s) sec")
return "\(sets) × \(hold)"
case .none:
return "\(sets) × \(reps) reps"
case .weight:
return "\(sets) × \(reps) × \(weightUnit.format(weight))"
}
}
}
@@ -0,0 +1,52 @@
//
// RoutineOrdering.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import SwiftUI
/// Pure reordering math shared by any list backed by `.onMove` + a `SyncEngine`
/// document write: given the row's current ids and a SwiftUI move gesture, compute
/// the renumbered `order` values and hand back only the ones that actually changed,
/// so a caller writes the smallest possible document diff.
enum RoutineOrdering {
/// Apply SwiftUI's `.onMove` semantics to `ids` (`Array.move(fromOffsets:toOffset:)`),
/// renumber the result 0n-1, and return only the `(id, newOrder)` pairs whose order
/// differs from `currentOrders[id]`. An id absent from `currentOrders` always comes
/// back (there's nothing to compare against, so it can't be assumed unchanged).
///
/// Comparing every renumbered position against the stored value rather than only
/// the ones the drag actually touched also normalizes legacy colliding order
/// values (e.g. two exercises both stored at order 0) the first time the list is
/// dragged: every row gets its correct 0n-1 position written, not just the moved one.
static func changedOrders(
ids: [String], currentOrders: [String: Int], from source: IndexSet, to destination: Int
) -> [(id: String, order: Int)] {
var reordered = ids
reordered.move(fromOffsets: source, toOffset: destination)
return reordered.enumerated().compactMap { index, id in
currentOrders[id] == index ? nil : (id: id, order: index)
}
}
}
/// Naming helper for the "duplicate this routine" action.
enum RoutineNaming {
/// The next free "«base» Copy" name: `"«base» Copy"` when that's unused, else
/// `"«base» Copy 2"`, `"«base» Copy 3"`, skipping any name already present in
/// `existing` (case-sensitive match).
static func uniqueName(base: String, existing: Set<String>) -> String {
let first = "\(base) Copy"
guard existing.contains(first) else { return first }
var suffix = 2
while existing.contains("\(base) Copy \(suffix)") {
suffix += 1
}
return "\(base) Copy \(suffix)"
}
}
+70
View File
@@ -0,0 +1,70 @@
import Foundation
import Testing
@testable import Workouts
/// Locks `ExerciseCatalog`'s section grouping and search the curated,
/// category-ordered view over the bundled exercise library. Every
/// `ExerciseMotionLibrary.exerciseNames` entry must land in a real authored category,
/// not the "Other" catch-all: a fall into "Other" here would mean a motion rig shipped
/// without a matching `info.md` category a real data gap worth surfacing, not
/// something to silently bucket away.
struct ExerciseCatalogTests {
@Test func everyBundledExerciseHasACuratedSection() {
let otherSection = ExerciseCatalog.sections.first { $0.title == ExerciseCatalog.otherTitle }
let uncategorized = otherSection?.names ?? []
#expect(uncategorized.isEmpty,
"expected every bundled exercise to have a curated category; found in Other: \(uncategorized)")
let allNames = Set(ExerciseCatalog.sections.flatMap(\.names))
#expect(allNames == Set(ExerciseMotionLibrary.exerciseNames))
}
@Test func sectionsCoverEveryNameExactlyOnce() {
let allNames = ExerciseCatalog.sections.flatMap(\.names)
#expect(allNames.count == Set(allNames).count, "an exercise appeared in more than one section")
}
@Test func namesAreAlphabeticalWithinASection() {
for section in ExerciseCatalog.sections {
#expect(section.names == section.names.sorted(), "\(section.title) is not alphabetized")
}
}
@Test func emptyQueryReturnsEverySection() {
let all = ExerciseCatalog.sections(matching: "")
#expect(all.flatMap(\.names).count == ExerciseCatalog.sections.flatMap(\.names).count)
let whitespaceOnly = ExerciseCatalog.sections(matching: " ")
#expect(whitespaceOnly.flatMap(\.names).count == ExerciseCatalog.sections.flatMap(\.names).count)
}
@Test func queryMatchesByExerciseName() {
let results = ExerciseCatalog.sections(matching: "Chest Press")
let names = results.flatMap(\.names)
#expect(names.contains("Chest Press"))
#expect(names.allSatisfy { $0.localizedCaseInsensitiveContains("Chest Press") })
}
@Test func queryMatchesByCategory() {
let results = ExerciseCatalog.sections(matching: "stretching")
let names = Set(results.flatMap(\.names))
#expect(!names.isEmpty)
for name in names {
let info = ExerciseInfoLibrary.info(for: name)
#expect(info?.category?.localizedCaseInsensitiveContains("stretching") == true)
}
}
@Test func queryMatchesByTarget() throws {
let info = try #require(ExerciseInfoLibrary.info(for: "Chest Press"))
let target = try #require(info.targets.first)
let results = ExerciseCatalog.sections(matching: target)
#expect(results.flatMap(\.names).contains("Chest Press"))
}
@Test func queryDropsSectionsWithNoMatches() {
let results = ExerciseCatalog.sections(matching: "zzz-not-a-real-exercise-zzz")
#expect(results.isEmpty)
}
}
+44
View File
@@ -0,0 +1,44 @@
import Foundation
import Testing
@testable import Workouts
/// Locks `RoutineNaming.uniqueName` the "duplicate this routine" naming scheme.
struct RoutineNamingTests {
@Test func firstCopyWhenNameIsFree() {
#expect(RoutineNaming.uniqueName(base: "Upper Body", existing: []) == "Upper Body Copy")
}
@Test func collisionAdvancesToCopyTwo() {
#expect(RoutineNaming.uniqueName(
base: "Upper Body", existing: ["Upper Body Copy"]
) == "Upper Body Copy 2")
}
@Test func multipleCollisionsAdvanceToCopyThree() {
#expect(RoutineNaming.uniqueName(
base: "Upper Body", existing: ["Upper Body Copy", "Upper Body Copy 2"]
) == "Upper Body Copy 3")
}
@Test func onlyLaterCollisionSkipsAhead() {
// "Copy" itself is free, but "Copy 2" is taken the first free slot is still
// "Copy" since collisions are only checked, not assumed contiguous.
#expect(RoutineNaming.uniqueName(
base: "Upper Body", existing: ["Upper Body Copy 2"]
) == "Upper Body Copy")
}
@Test func matchIsCaseSensitive() {
// A differently-cased existing name doesn't count as a collision.
#expect(RoutineNaming.uniqueName(
base: "Upper Body", existing: ["upper body copy"]
) == "Upper Body Copy")
}
@Test func unrelatedNamesDontCollide() {
#expect(RoutineNaming.uniqueName(
base: "Upper Body", existing: ["Lower Body", "Core"]
) == "Upper Body Copy")
}
}
+68
View File
@@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import Workouts
/// Locks `RoutineOrdering.changedOrders` the pure `.onMove` renumbered-order math
/// shared by any list backed by a `SyncEngine` document write. Results are compared as
/// `"id:order"` strings since tuple arrays aren't `Equatable`.
struct RoutineOrderingTests {
private func describe(_ changes: [(id: String, order: Int)]) -> [String] {
changes.map { "\($0.id):\($0.order)" }
}
@Test func moveProducesMinimalChangedSet() {
// A, B, C, D at orders 0...3; drag C (index 2) to the front. B and A shift by
// one; D already last keeps its order and must NOT appear in the result.
let changes = RoutineOrdering.changedOrders(
ids: ["A", "B", "C", "D"],
currentOrders: ["A": 0, "B": 1, "C": 2, "D": 3],
from: IndexSet(integer: 2), to: 0
)
#expect(Set(describe(changes)) == Set(["C:0", "A:1", "B:2"]))
}
@Test func noOpMoveYieldsEmptyChangeSet() {
// Dropping a row back where it started (or an app-level tap that fires the
// same source/destination) must produce zero document churn.
let changes = RoutineOrdering.changedOrders(
ids: ["A", "B", "C", "D"],
currentOrders: ["A": 0, "B": 1, "C": 2, "D": 3],
from: IndexSet(integer: 0), to: 0
)
#expect(changes.isEmpty)
}
@Test func legacyDuplicateOrdersNormalizeOnFirstDrag() {
// Legacy data where every row collided at order 0. Even a no-op array move
// (A stays first) must renumber every row that isn't already at its correct
// 0...n-1 position the whole point of comparing against the stored value
// rather than only the dragged row.
let changes = RoutineOrdering.changedOrders(
ids: ["A", "B", "C", "D"],
currentOrders: ["A": 0, "B": 0, "C": 0, "D": 0],
from: IndexSet(integer: 0), to: 0
)
#expect(Set(describe(changes)) == Set(["B:1", "C:2", "D:3"]))
}
@Test func idMissingFromCurrentOrdersIsAlwaysIncluded() {
// A newly-added row with no prior stored order can't be assumed unchanged.
let changes = RoutineOrdering.changedOrders(
ids: ["A", "B"],
currentOrders: ["A": 0],
from: IndexSet(integer: 0), to: 0
)
#expect(Set(describe(changes)) == Set(["B:1"]))
}
@Test func moveToEndRenumbersOnlyDisplacedRows() {
let changes = RoutineOrdering.changedOrders(
ids: ["A", "B", "C", "D"],
currentOrders: ["A": 0, "B": 1, "C": 2, "D": 3],
from: IndexSet(integer: 0), to: 4
)
// A moves to the end; B, C, D each shift down one; nothing keeps its old order.
#expect(Set(describe(changes)) == Set(["B:0", "C:1", "D:2", "A:3"]))
}
}