-
Notifications
You must be signed in to change notification settings - Fork 0
feat: watch app + intents + race tools + history/favorites + shared fixes #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
54 commits
Select commit
Hold shift + click to select a range
c74f31f
feat: add Siri App Intents for pace/speed conversion
saadjs f480977
feat(race): add race finish time and even split calculators
saadjs 5198587
feat(history): add recent conversions history with persistence
saadjs 098e946
feat(nav): add toolbar buttons for race time, splits, and history
saadjs f6b7b34
feat(watch): add Apple Watch companion app target
saadjs 5e05e08
feat(widget): add WidgetKit home/lock screen widget
saadjs 311c67b
build(xcode): add watchOS, WidgetKit, and App Intents targets to project
saadjs 2127931
fix(widget): add proper Info.plist with NSExtension dict to fix previ…
saadjs 99c6350
fix(widget): add CFBundleExecutable to Info.plist
saadjs b8ebf7f
fix(history): deduplicate saves and trigger on direction/unit changes
saadjs 9a1326a
fix(history): save conversion when keyboard dismisses
saadjs f97c965
chore: remove widget extension target and files
saadjs ef829c2
fix: add nonisolated to ConversionEngine enum
saadjs a211eff
feat(calculator): add speed and pace to race calculator and even spli…
saadjs 364291a
docs: add AGENTS.md with git commit guidelines
saadjs 86eb107
update AGENTS.md
saadjs 9e9cd45
feat(favorites): add FavoritesStore model and tests
saadjs ce871f2
feat(calculator): add negative split logic and tests
saadjs c49dd52
feat(calculator): add negative split calculator view
saadjs df7e53a
feat(favorites): add FavoritesView and star toggle in history
saadjs d95b067
feat(ux): add haptic feedback on result changes
saadjs e1cdb4e
feat(nav): consolidate toolbar into glass popup menu with star button
saadjs cf9c779
feat(watch): add converter and race calculator tabs
saadjs 101fde1
fix(intents): fix pace label for kph and simplify speed-to-pace conve…
saadjs 186ce15
docs: update AGENTS.md
saadjs 4338389
fix(watch): make converter home
saadjs 7f28dd5
fix(pr-1): address review feedback
saadjs 2d10863
add shared conversions for pace and speed
saadjs 56f500b
delegate formatting and conversion to shared
saadjs b2cf091
add icon for watch
saadjs d1617c5
move conversion to shared
saadjs 0b6cd6b
add shared group to project
saadjs 106f9d6
fix(ui): use split-content haptic trigger
saadjs ea961f2
fix(pr-1): address review feedback batch 2
saadjs 1e5a634
fix(race): preserve non-increasing split order when rebalancing round…
saadjs f42f607
fix(negative-splits): ensure strictly-decreasing order when adjusting…
saadjs 5ed8586
refactor(shared): move models to Shared, eliminate duplicate types
saadjs 4cdf329
fix(shared): shorten marathon label to 'Full' for watch display
saadjs 433dd22
fix(watch): replace wheel pickers with digitalCrownRotation, fix runt…
saadjs c2ee302
Merge branch 'watch-fix' into feat/v2
saadjs a708b71
fix(parse): reject seconds >= 60 in parsePace
saadjs 0a8eb64
fix(splits): allow equal adj splits from rounding in negative diff path
saadjs 964d480
fix(units): convert inputs on unit switch
saadjs 1996623
test(ui): fix stale expectations
saadjs ac1db4d
fix(app): lock iphone to portrait
saadjs 0888715
fix(race): preserve valid negative splits
saadjs 147bf27
fix(watchkit): add Embed Watch Content build phase and dependencies
saadjs c2106d1
doc updates
saadjs 3eecf91
fix(app): address review regressions
saadjs 2575229
fix(a11y): restore favorite actions
saadjs 5e6f2b5
fix(build): restore watch dependency
saadjs c57c714
fix(parse): reject empty segments in parseDuration
saadjs 1090ac8
fix(units): add convertDropSecondsBetweenUnits, fix semantic misuse i…
saadjs 31367f0
fix(watch): correct crown range per field, extract shared unit binding
saadjs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # Git Commits Guidelines | ||
|
|
||
| - DO NOT COMMIT automatically unless asked to do so by the user. | ||
| - Always ask for confirmation before making any commits to the repository. | ||
| - This ensures that you have the user's approval and that they are aware of the changes being made to the codebase. | ||
|
|
||
| When committing, always use atomic commits format with Conventional Commits format. This means that each commit should represent a single logical change to the codebase, and the commit message should follow the Conventional Commits format, which includes a type, scope, and description of the change. | ||
|
|
||
| Be extremely concise in commit messages, sacrifice grammar for sake of conciseness. | ||
|
|
||
| Don't add co-authored by any AI tools in any commits. e.g. Claude, Copilot, ChatGPT, Gemini, etc. | ||
|
|
||
| # After Review | ||
|
|
||
| - When review has been completed, and there are changes to be made, add regression tests first reproducing the findings. | ||
| - After adding regression tests, make the necessary code changes to fix the issues identified during the review process. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| import Foundation | ||
|
|
||
| enum RaceCalculator { | ||
| enum Distance: String, CaseIterable, Identifiable { | ||
| case fiveK = "5K" | ||
| case tenK = "10K" | ||
| case halfMarathon = "Half Marathon" | ||
| case marathon = "Marathon" | ||
| case custom = "Custom" | ||
|
|
||
| var id: String { rawValue } | ||
|
|
||
| var miles: Double? { | ||
| switch self { | ||
| case .fiveK: return 3.10686 | ||
| case .tenK: return 6.21371 | ||
| case .halfMarathon: return 13.1094 | ||
| case .marathon: return 26.2188 | ||
| case .custom: return nil | ||
| } | ||
| } | ||
|
|
||
| var kilometers: Double? { | ||
| switch self { | ||
| case .fiveK: return 5.0 | ||
| case .tenK: return 10.0 | ||
| case .halfMarathon: return 21.0975 | ||
| case .marathon: return 42.195 | ||
| case .custom: return nil | ||
| } | ||
| } | ||
|
|
||
| var shortLabel: String { | ||
| switch self { | ||
| case .halfMarathon: return "Half" | ||
| case .marathon: return "Full" | ||
| default: return rawValue | ||
| } | ||
| } | ||
|
|
||
| func distance(unit: SpeedUnit) -> Double? { | ||
| switch unit { | ||
| case .mph: return miles | ||
| case .kph: return kilometers | ||
| } | ||
| } | ||
|
|
||
| static var standardCases: [Distance] { | ||
| allCases.filter { $0 != .custom } | ||
| } | ||
| } | ||
|
|
||
| /// Given pace (min/mile or min/km) and distance in the same unit, return total seconds. | ||
| static func finishTime(paceMinutes: Double, distanceInUnits: Double) -> Int { | ||
| Int(round(paceMinutes * distanceInUnits * 60)) | ||
| } | ||
|
|
||
| /// Format total seconds as "h:mm:ss" or "mm:ss" if under 1 hour. | ||
| static func formatDuration(_ totalSeconds: Int) -> String { | ||
| guard totalSeconds >= 0 else { return "0:00" } | ||
| let hours = totalSeconds / 3600 | ||
| let minutes = (totalSeconds % 3600) / 60 | ||
| let seconds = totalSeconds % 60 | ||
|
|
||
| if hours > 0 { | ||
| return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" | ||
| } else { | ||
| return "\(minutes):\(String(format: "%02d", seconds))" | ||
| } | ||
| } | ||
|
|
||
| /// Given finish time (total seconds) and distance, return pace in minutes. | ||
| static func requiredPace(totalSeconds: Int, distanceInUnits: Double) -> Double { | ||
| guard distanceInUnits > 0 else { return 0 } | ||
| return Double(totalSeconds) / 60.0 / distanceInUnits | ||
| } | ||
|
|
||
| /// Parse "h:mm:ss" or "mm:ss" into total seconds, returns nil if invalid. | ||
| static func parseDuration(_ input: String) -> Int? { | ||
| let trimmed = input.trimmingCharacters(in: .whitespaces) | ||
| guard !trimmed.isEmpty else { return nil } | ||
|
|
||
| let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false) | ||
| guard !parts.isEmpty, parts.count <= 3, parts.allSatisfy({ !$0.isEmpty }) else { return nil } | ||
|
|
||
| let ints = parts.compactMap { Int($0) } | ||
| guard ints.count == parts.count else { return nil } | ||
|
|
||
| switch ints.count { | ||
| case 1: | ||
| // Just minutes | ||
| guard ints[0] >= 0 else { return nil } | ||
| return ints[0] * 60 | ||
| case 2: | ||
| // mm:ss | ||
| guard ints[0] >= 0, ints[1] >= 0, ints[1] < 60 else { return nil } | ||
| return ints[0] * 60 + ints[1] | ||
| case 3: | ||
| // h:mm:ss | ||
| guard ints[0] >= 0, ints[1] >= 0, ints[1] < 60, ints[2] >= 0, ints[2] < 60 else { return nil } | ||
| return ints[0] * 3600 + ints[1] * 60 + ints[2] | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// Compute negative (progressive) splits. | ||
| /// - Parameters: | ||
| /// - totalSeconds: Target finish time in seconds | ||
| /// - distanceInUnits: Total distance in miles or km | ||
| /// - dropSeconds: Seconds faster per split (positive value = each split is faster) | ||
| /// - Returns: Array of (splitDistance, splitSeconds) tuples | ||
| static func negativeSplits(totalSeconds: Int, distanceInUnits: Double, dropSeconds: Double) -> [(distance: Double, seconds: Int)] { | ||
| guard distanceInUnits > 0, totalSeconds > 0, dropSeconds >= 0 else { return [] } | ||
|
|
||
| let fullSplits = Int(distanceInUnits) | ||
| let partial = distanceInUnits - Double(fullSplits) | ||
| let splitCount = fullSplits + (partial > 0.001 ? 1 : 0) | ||
| guard splitCount > 0 else { return [] } | ||
|
|
||
| // Calculate base pace for first split such that total = totalSeconds | ||
| // Split i has pace: basePace - i * dropSeconds (for full mile/km) | ||
| // Last split is scaled by partial distance | ||
| // Sum = sum(basePace - i*drop, i=0..fullSplits-1) + (basePace - fullSplits*drop)*partial | ||
| // totalSeconds = fullSplits*basePace - drop*(0+1+...+(fullSplits-1)) + partial*(basePace - fullSplits*drop) | ||
| // totalSeconds = basePace*(fullSplits + partial) - drop*(fullSplits*(fullSplits-1)/2 + partial*fullSplits) | ||
|
|
||
| let n = Double(fullSplits) | ||
| let effectiveDistance = n + (partial > 0.001 ? partial : 0) | ||
| let dropSum = dropSeconds * (n * (n - 1) / 2 + (partial > 0.001 ? partial * n : 0)) | ||
|
|
||
| guard effectiveDistance > 0 else { return [] } | ||
| let basePace = (Double(totalSeconds) + dropSum) / effectiveDistance | ||
| guard basePace - Double(splitCount - 1) * dropSeconds > 0 else { return [] } | ||
|
|
||
| var distances: [Double] = [] | ||
| var splitTimes: [Int] = [] | ||
| for i in 0..<splitCount { | ||
| let splitPace = basePace - Double(i) * dropSeconds | ||
| let dist: Double | ||
| if i == splitCount - 1 && partial > 0.001 { | ||
| dist = partial | ||
| } else { | ||
| dist = 1.0 | ||
| } | ||
| let splitTime = max(Int(round(splitPace * dist)), 1) | ||
| distances.append(dist) | ||
| splitTimes.append(splitTime) | ||
| } | ||
|
|
||
| let difference = totalSeconds - splitTimes.reduce(0, +) | ||
| if difference > 0 { | ||
| // Add rounding remainder to the first (slowest) split to preserve non-increasing order | ||
| splitTimes[0] += difference | ||
| } else if difference < 0 { | ||
| // Remove rounding overshoot from the end back toward the start. Each split can be | ||
| // reduced only down to the next split (or 1s for the final split) so the result | ||
| // stays non-increasing even when rounding produced equal adjacent splits. | ||
| var overshoot = -difference | ||
| for index in stride(from: splitTimes.count - 1, through: 0, by: -1) where overshoot > 0 { | ||
| let minimum = index == splitTimes.count - 1 ? 1 : splitTimes[index + 1] | ||
| let reducible = splitTimes[index] - minimum | ||
| guard reducible >= 0 else { return [] } | ||
| let adjustment = min(reducible, overshoot) | ||
| splitTimes[index] -= adjustment | ||
| overshoot -= adjustment | ||
| } | ||
| guard overshoot == 0 else { return [] } | ||
| } | ||
|
|
||
| return zip(distances, splitTimes).map { (distance: $0.0, seconds: $0.1) } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
11 changes: 11 additions & 0 deletions
11
pace-to-mph WatchKit App/Assets.xcassets/AccentColor.colorset/Contents.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "colors" : [ | ||
| { | ||
| "idiom" : "universal" | ||
| } | ||
| ], | ||
| "info" : { | ||
| "author" : "xcode", | ||
| "version" : 1 | ||
| } | ||
| } |
Binary file added
BIN
+22.2 KB
...o-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions
14
pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| { | ||
| "images" : [ | ||
| { | ||
| "filename" : "App-Icon-1024x1024@1x.png", | ||
| "idiom" : "universal", | ||
| "platform" : "watchos", | ||
| "size" : "1024x1024" | ||
| } | ||
| ], | ||
| "info" : { | ||
| "author" : "xcode", | ||
| "version" : 1 | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.