Skip to content
Merged
Show file tree
Hide file tree
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 Mar 1, 2026
f480977
feat(race): add race finish time and even split calculators
saadjs Mar 1, 2026
5198587
feat(history): add recent conversions history with persistence
saadjs Mar 1, 2026
098e946
feat(nav): add toolbar buttons for race time, splits, and history
saadjs Mar 1, 2026
f6b7b34
feat(watch): add Apple Watch companion app target
saadjs Mar 1, 2026
5e05e08
feat(widget): add WidgetKit home/lock screen widget
saadjs Mar 1, 2026
311c67b
build(xcode): add watchOS, WidgetKit, and App Intents targets to project
saadjs Mar 1, 2026
2127931
fix(widget): add proper Info.plist with NSExtension dict to fix previ…
saadjs Mar 1, 2026
99c6350
fix(widget): add CFBundleExecutable to Info.plist
saadjs Mar 1, 2026
b8ebf7f
fix(history): deduplicate saves and trigger on direction/unit changes
saadjs Mar 1, 2026
9a1326a
fix(history): save conversion when keyboard dismisses
saadjs Mar 1, 2026
f97c965
chore: remove widget extension target and files
saadjs Mar 1, 2026
ef829c2
fix: add nonisolated to ConversionEngine enum
saadjs Mar 1, 2026
a211eff
feat(calculator): add speed and pace to race calculator and even spli…
saadjs Mar 1, 2026
364291a
docs: add AGENTS.md with git commit guidelines
saadjs Mar 1, 2026
86eb107
update AGENTS.md
saadjs Mar 1, 2026
9e9cd45
feat(favorites): add FavoritesStore model and tests
saadjs Mar 1, 2026
ce871f2
feat(calculator): add negative split logic and tests
saadjs Mar 1, 2026
c49dd52
feat(calculator): add negative split calculator view
saadjs Mar 1, 2026
df7e53a
feat(favorites): add FavoritesView and star toggle in history
saadjs Mar 1, 2026
d95b067
feat(ux): add haptic feedback on result changes
saadjs Mar 1, 2026
e1cdb4e
feat(nav): consolidate toolbar into glass popup menu with star button
saadjs Mar 1, 2026
cf9c779
feat(watch): add converter and race calculator tabs
saadjs Mar 1, 2026
101fde1
fix(intents): fix pace label for kph and simplify speed-to-pace conve…
saadjs Mar 1, 2026
186ce15
docs: update AGENTS.md
saadjs Mar 1, 2026
4338389
fix(watch): make converter home
saadjs Mar 1, 2026
7f28dd5
fix(pr-1): address review feedback
saadjs Mar 1, 2026
2d10863
add shared conversions for pace and speed
saadjs Mar 1, 2026
56f500b
delegate formatting and conversion to shared
saadjs Mar 1, 2026
b2cf091
add icon for watch
saadjs Mar 1, 2026
d1617c5
move conversion to shared
saadjs Mar 1, 2026
0b6cd6b
add shared group to project
saadjs Mar 1, 2026
106f9d6
fix(ui): use split-content haptic trigger
saadjs Mar 1, 2026
ea961f2
fix(pr-1): address review feedback batch 2
saadjs Mar 1, 2026
1e5a634
fix(race): preserve non-increasing split order when rebalancing round…
saadjs Mar 1, 2026
f42f607
fix(negative-splits): ensure strictly-decreasing order when adjusting…
saadjs Mar 1, 2026
5ed8586
refactor(shared): move models to Shared, eliminate duplicate types
saadjs Mar 1, 2026
4cdf329
fix(shared): shorten marathon label to 'Full' for watch display
saadjs Mar 1, 2026
433dd22
fix(watch): replace wheel pickers with digitalCrownRotation, fix runt…
saadjs Mar 1, 2026
c2ee302
Merge branch 'watch-fix' into feat/v2
saadjs Mar 1, 2026
a708b71
fix(parse): reject seconds >= 60 in parsePace
saadjs Mar 1, 2026
0a8eb64
fix(splits): allow equal adj splits from rounding in negative diff path
saadjs Mar 1, 2026
964d480
fix(units): convert inputs on unit switch
saadjs Mar 1, 2026
1996623
test(ui): fix stale expectations
saadjs Mar 1, 2026
ac1db4d
fix(app): lock iphone to portrait
saadjs Mar 1, 2026
0888715
fix(race): preserve valid negative splits
saadjs Mar 1, 2026
147bf27
fix(watchkit): add Embed Watch Content build phase and dependencies
saadjs Mar 1, 2026
c2106d1
doc updates
saadjs Mar 1, 2026
3eecf91
fix(app): address review regressions
saadjs Mar 1, 2026
2575229
fix(a11y): restore favorite actions
saadjs Mar 2, 2026
5e6f2b5
fix(build): restore watch dependency
saadjs Mar 2, 2026
c57c714
fix(parse): reject empty segments in parseDuration
saadjs Mar 2, 2026
1090ac8
fix(units): add convertDropSecondsBetweenUnits, fix semantic misuse i…
saadjs Mar 2, 2026
31367f0
fix(watch): correct crown range per field, extract shared unit binding
saadjs Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
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.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ enum SpeedUnit: String, CaseIterable {
}
}

enum ConversionEngine {
nonisolated enum ConversionEngine {
static let kmPerMile: Double = 1.60934

// MARK: - Parsing
Expand All @@ -56,7 +56,8 @@ enum ConversionEngine {
if segments.count == 2 {
guard let minutes = Double(String(segments[0])),
let seconds = Double(String(segments[1])),
minutes.isFinite, seconds.isFinite else {
minutes.isFinite, seconds.isFinite,
seconds >= 0, seconds < 60 else {
return nil
}
let total = minutes + seconds / 60.0
Expand Down Expand Up @@ -113,11 +114,46 @@ enum ConversionEngine {
return from == .mph ? paceMinutes / kmPerMile : paceMinutes * kmPerMile
}

static func convertDistanceBetweenUnits(_ distance: Double, from: SpeedUnit, to: SpeedUnit) -> Double {
guard from != to else { return distance }
return from == .mph ? distance * kmPerMile : distance / kmPerMile
}

static func convertSpeedBetweenUnits(_ speed: Double, from: SpeedUnit, to: SpeedUnit) -> Double {
guard from != to else { return speed }
return from == .mph ? speed * kmPerMile : speed / kmPerMile
}

/// Convert a time-per-distance delta (e.g. drop seconds per split) between unit systems.
static func convertDropSecondsBetweenUnits(_ seconds: Double, from: SpeedUnit, to: SpeedUnit) -> Double {
guard from != to else { return seconds }
return from == .mph ? seconds / kmPerMile : seconds * kmPerMile
}

static func convertPaceInput(_ paceInput: String, from: SpeedUnit, to: SpeedUnit) -> String {
guard from != to, let pace = parsePace(paceInput) else { return paceInput }
let converted = convertPaceBetweenUnits(pace, from: from, to: to)
return formatPace(converted) ?? paceInput
}

static func convertPaceComponents(minutes: Int, seconds: Int, from: SpeedUnit, to: SpeedUnit) -> (minutes: Int, seconds: Int)? {
guard minutes >= 0, seconds >= 0, seconds < 60 else { return nil }
let paceMinutes = Double(minutes) + Double(seconds) / 60.0
guard paceMinutes > 0 else { return nil }

let converted = convertPaceBetweenUnits(paceMinutes, from: from, to: to)
let totalSeconds = Int(round(converted * 60.0))
guard totalSeconds > 0 else { return nil }

return (minutes: totalSeconds / 60, seconds: totalSeconds % 60)
}

static func convertDistanceInput(_ distanceInput: String, from: SpeedUnit, to: SpeedUnit) -> String {
guard from != to, let distance = parseSpeed(distanceInput) else { return distanceInput }
let converted = convertDistanceBetweenUnits(distance, from: from, to: to)
return formatDecimalInput(converted)
}

// MARK: - Input Sanitization

static func sanitizeInput(direction: ConversionDirection, value: String) -> String {
Expand Down Expand Up @@ -147,4 +183,18 @@ enum ConversionEngine {
return formatPace(paceMinutes) ?? ""
}
}

private static func formatDecimalInput(_ value: Double) -> String {
var formatted = String(format: "%.2f", value)

while formatted.contains(".") && formatted.last == "0" {
formatted.removeLast()
}

if formatted.last == "." {
formatted.removeLast()
}

return formatted
}
}
173 changes: 173 additions & 0 deletions Shared/RaceCalculator.swift
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 [] }
}
Comment thread
saadjs marked this conversation as resolved.

return zip(distances, splitTimes).map { (distance: $0.0, seconds: $0.1) }
}
}
18 changes: 11 additions & 7 deletions docs/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
</head>
<body>
<h1>Privacy Policy</h1>
<p class="updated">Last updated: February 28, 2025</p>
<p class="updated">Last updated: March 1, 2026</p>

<p>
<strong>RunPace – Speed Converter</strong> ("the app") is a simple utility for
converting running pace to speed and back. This policy explains what data we
collect — which is none.
<strong>RunPace</strong> ("the app") is a utility for converting running pace to
speed, calculating race finish times, and planning split strategies. This policy
explains what data we collect — which is none.
</p>

<h2>No Data Collection</h2>
Expand All @@ -41,10 +41,14 @@ <h2>No Data Collection</h2>

<h2>Local Storage Only</h2>
<p>
Your preferred unit (MPH or KM/H) and conversion direction are saved locally
on your device using Apple's standard <code>UserDefaults</code> API. This data
never leaves your device.
The following data is saved locally on your device using Apple's standard
<code>UserDefaults</code> API and never leaves your device:
</p>
<ul>
<li>Your preferred unit (MPH or KM/H) and conversion direction</li>
<li>Conversion history</li>
<li>Pinned favorite conversions</li>
</ul>

<h2>No Network Access</h2>
<p>
Expand Down
39 changes: 34 additions & 5 deletions docs/support.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
</head>
<body>
<h1>RunPace Support</h1>
<p class="subtitle">Speed Converter for Runners</p>
<p class="subtitle">Pace Converter &amp; Race Tools for Runners</p>

<h2>Frequently Asked Questions</h2>

Expand All @@ -46,14 +46,43 @@ <h2>How do I convert pace to speed?</h2>

<h2>How do I switch between miles and kilometers?</h2>
<p>
Use the <strong>MPH / KM/H</strong> toggle in the control panel at the bottom.
Your input will automatically be converted to the new unit.
Use the <strong>Mile / KM</strong> toggle in the converter. Your input will
automatically be converted to the new unit.
</p>

<h2>Where is the reference table?</h2>
<p>
Tap the table icon in the top-right corner to open a full reference chart of
common paces and their equivalent speeds in both MPH and KM/H.
Tap the <strong>⋯</strong> menu button in the top-right corner and choose
<strong>Reference Table</strong> to open a full chart of common paces and
their equivalent speeds in both MPH and KM/H.
</p>

<h2>How do I save a conversion as a favorite?</h2>
<p>
While a result is showing in the converter, tap the <strong>★ star</strong>
button to pin it. View all your pinned conversions any time by opening the
<strong>⋯</strong> menu and choosing <strong>Favorites</strong>.
</p>

<h2>How do I calculate my finish time for a race?</h2>
<p>
Open the <strong>⋯</strong> menu and tap <strong>Race Calculator</strong>.
Enter your target pace, choose a distance (5K, 10K, Half, Full, or a custom
distance), and the app will instantly show your projected finish time.
</p>

<h2>What is the Negative Split Calculator?</h2>
<p>
A negative split strategy means running each mile (or km) a little faster than
the previous one. Open <strong>Negative Splits</strong> from the <strong>⋯</strong>
menu, enter your total target time, distance, and how many seconds per split
you want to drop, and the app will generate a complete split plan for you.
</p>

<h2>Is there an Apple Watch app?</h2>
<p>
Yes! RunPace includes a companion Watch app with a pace ↔ speed converter
and a race finish-time calculator, controllable via the Digital Crown.
</p>

<h2>Does the app require an internet connection?</h2>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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
}
}
Loading