diff --git a/Workouts/Views/Exercises/ExerciseCatalog.swift b/Workouts/Views/Exercises/ExerciseCatalog.swift new file mode 100644 index 0000000..a93fb32 --- /dev/null +++ b/Workouts/Views/Exercises/ExerciseCatalog.swift @@ -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) + } + } +} diff --git a/Workouts/Views/Exercises/ExerciseDocument+PlanSummary.swift b/Workouts/Views/Exercises/ExerciseDocument+PlanSummary.swift new file mode 100644 index 0000000..1ecac24 --- /dev/null +++ b/Workouts/Views/Exercises/ExerciseDocument+PlanSummary.swift @@ -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))" + } + } +} diff --git a/Workouts/Views/Routines/RoutineOrdering.swift b/Workouts/Views/Routines/RoutineOrdering.swift new file mode 100644 index 0000000..5511d00 --- /dev/null +++ b/Workouts/Views/Routines/RoutineOrdering.swift @@ -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 0…n-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 0…n-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 { + 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)" + } +} diff --git a/WorkoutsTests/ExerciseCatalogTests.swift b/WorkoutsTests/ExerciseCatalogTests.swift new file mode 100644 index 0000000..831f3a0 --- /dev/null +++ b/WorkoutsTests/ExerciseCatalogTests.swift @@ -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) + } +} diff --git a/WorkoutsTests/RoutineNamingTests.swift b/WorkoutsTests/RoutineNamingTests.swift new file mode 100644 index 0000000..d474d2a --- /dev/null +++ b/WorkoutsTests/RoutineNamingTests.swift @@ -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") + } +} diff --git a/WorkoutsTests/RoutineOrderingTests.swift b/WorkoutsTests/RoutineOrderingTests.swift new file mode 100644 index 0000000..aea407b --- /dev/null +++ b/WorkoutsTests/RoutineOrderingTests.swift @@ -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"])) + } +}