Skip to content

Commit 6a3b743

Browse files
OneMuppetclaude
andcommitted
Scaffold the OpenProgression iOS app (SwiftUI, offline, bundled data)
First scaffold of the native app, built in-repo under ios/. Reviewed by rut (David's stand-in): HAPPY 8/10 for a first milestone, zero crashers / wrong-data renders, top fix (hero workout-name) applied. - SwiftUI app (iOS 17+), no backend; bundles /data (benchmarks, levels, a full year of programming) so it runs offline. XcodeGen project (project.yml). - Design system: dark + teal brand, the 7-level gradient mark, reusable cards/pills. - Codable models handle the polymorphic `standards` shape and tolerate the `_description` doc keys in calculator.json. - Screens: Today (daily session rendered at a selectable level + gender), Calculator (bodyweight-relative scoring + weakest-link, provisional results), Benchmarks (by category), Levels. - Builds clean (xcodebuild), runs in the simulator against today's real session. Also fixes a data bug surfaced while modeling: 157 Tue/Thu/Sat sessions had the strength field named `pres` instead of `prescription` (renamed in data + public). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4ef85a7 commit 6a3b743

36 files changed

Lines changed: 57740 additions & 628 deletions

data/sessions.json

Lines changed: 314 additions & 314 deletions
Large diffs are not rendered by default.

ios/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Generated by XcodeGen (run: xcodegen generate)
2+
*.xcodeproj/
3+
# Build output
4+
build/
5+
DerivedData/
6+
*.xcuserstate
7+
.DS_Store
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import SwiftUI
2+
3+
@main
4+
struct OpenProgressionApp: App {
5+
@State private var store = DataStore()
6+
var body: some Scene {
7+
WindowGroup {
8+
RootView()
9+
.environment(store)
10+
.preferredColorScheme(.dark)
11+
.tint(Theme.primary)
12+
}
13+
}
14+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import SwiftUI
2+
3+
struct RootView: View {
4+
@State private var tab = RootView.initialTab
5+
static var initialTab: Int {
6+
switch ProcessInfo.processInfo.environment["OP_TAB"] {
7+
case "calculator": return 1; case "benchmarks": return 2; case "levels": return 3; default: return 0
8+
}
9+
}
10+
var body: some View {
11+
TabView(selection: $tab) {
12+
TodayView().tag(0).tabItem { Label("Today", systemImage: "bolt.fill") }
13+
CalculatorView().tag(1).tabItem { Label("Calculator", systemImage: "slider.horizontal.3") }
14+
BenchmarksView().tag(2).tabItem { Label("Benchmarks", systemImage: "chart.bar.fill") }
15+
LevelsView().tag(3).tabItem { Label("Levels", systemImage: "square.stack.3d.up.fill") }
16+
}
17+
.tint(Theme.primary)
18+
}
19+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Foundation
2+
import Observation
3+
4+
@Observable
5+
final class DataStore {
6+
var levels: [OPLevel] = []
7+
var categories: [OPCategory] = []
8+
var sessions: [Session] = []
9+
private var metconsByCode: [String: Metcon] = [:]
10+
var benchmarks: [Benchmark] = [] // flattened across categories
11+
var benchmarksByCategory: [String: [Benchmark]] = [:]
12+
var calc: CalculatorConfig?
13+
14+
init() { load() }
15+
16+
private func decode<T: Decodable>(_ type: T.Type, _ name: String, subdir: String = "data") -> T? {
17+
guard let url = Bundle.main.url(forResource: name, withExtension: "json", subdirectory: subdir),
18+
let data = try? Data(contentsOf: url) else { return nil }
19+
do { return try JSONDecoder().decode(T.self, from: data) }
20+
catch { print("decode \(name) failed: \(error)"); return nil }
21+
}
22+
23+
private func load() {
24+
levels = decode(LevelsFile.self, "levels")?.levels ?? []
25+
categories = decode(CategoriesFile.self, "categories")?.categories ?? []
26+
sessions = (decode(SessionsFile.self, "sessions")?.sessions ?? []).sorted { $0.date < $1.date }
27+
let metcons = decode(MetconsFile.self, "metcons")?.metcons ?? []
28+
metconsByCode = Dictionary(uniqueKeysWithValues: metcons.map { ($0.code, $0) })
29+
calc = decode(CalculatorConfig.self, "calculator")
30+
for cat in ["squatting","pulling","pressing","olympic_lifting","gymnastics","monostructural","bodyweight","endurance"] {
31+
if let f = decode(BenchmarkFile.self, cat, subdir: "data/benchmarks") {
32+
benchmarksByCategory[f.category] = f.benchmarks
33+
benchmarks.append(contentsOf: f.benchmarks)
34+
}
35+
}
36+
}
37+
38+
func metcon(_ code: String?) -> Metcon? { code.flatMap { metconsByCode[$0] } }
39+
func level(number: Int) -> OPLevel? { levels.first { $0.number == number } }
40+
41+
/// The session for a given calendar date, clamped into the available range.
42+
func session(for date: Date) -> Session? {
43+
guard !sessions.isEmpty else { return nil }
44+
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; f.timeZone = TimeZone(identifier: "UTC")
45+
let key = f.string(from: date)
46+
if let exact = sessions.first(where: { $0.date == key }) { return exact }
47+
if let upcoming = sessions.first(where: { $0.date >= key }) { return upcoming }
48+
return sessions.last
49+
}
50+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import SwiftUI
2+
3+
// Wordmark: "Open" white + "Progression" teal
4+
struct Wordmark: View {
5+
var size: CGFloat = 20
6+
var body: some View {
7+
HStack(spacing: 0) {
8+
Text("Open").foregroundStyle(Theme.text)
9+
Text("Progression").foregroundStyle(Theme.primary)
10+
}
11+
.font(.brand(size))
12+
.tracking(-0.5)
13+
}
14+
}
15+
16+
// The signature 7-dot level gradient mark
17+
struct LevelDots: View {
18+
var size: CGFloat = 7
19+
var body: some View {
20+
HStack(spacing: 4) {
21+
ForEach(0..<7, id: \.self) { i in
22+
Circle().fill(Theme.levelColors[i]).frame(width: size, height: size)
23+
}
24+
}
25+
}
26+
}
27+
28+
// Section label (uppercase, tracked)
29+
struct SectionLabel: View {
30+
let text: String
31+
var color: Color = Theme.textDim
32+
var body: some View {
33+
Text(text.uppercased())
34+
.font(.system(size: 12, weight: .bold))
35+
.tracking(1.4)
36+
.foregroundStyle(color)
37+
}
38+
}
39+
40+
// A pill for a level (colored)
41+
struct LevelPill: View {
42+
let name: String
43+
let number: Int
44+
var selected: Bool = false
45+
var body: some View {
46+
let c = Theme.levelColor(number)
47+
Text(name)
48+
.font(.system(size: 13, weight: .semibold))
49+
.foregroundStyle(selected ? Color.black : c)
50+
.padding(.horizontal, 12).padding(.vertical, 7)
51+
.background(selected ? c : c.opacity(0.14), in: Capsule())
52+
.overlay(Capsule().strokeBorder(c.opacity(selected ? 0 : 0.35), lineWidth: 1))
53+
}
54+
}
55+
56+
// Small chip/tag
57+
struct Chip: View {
58+
let text: String
59+
var color: Color = Theme.primary
60+
var filled: Bool = false
61+
var body: some View {
62+
Text(text)
63+
.font(.system(size: 12, weight: .semibold))
64+
.foregroundStyle(filled ? Color.black : color)
65+
.padding(.horizontal, 10).padding(.vertical, 5)
66+
.background(filled ? color : color.opacity(0.12), in: Capsule())
67+
}
68+
}
69+
70+
// A labeled stat
71+
struct Stat: View {
72+
let value: String
73+
let label: String
74+
var body: some View {
75+
VStack(spacing: 2) {
76+
Text(value).font(.display(20, .bold)).foregroundStyle(Theme.text)
77+
Text(label.uppercased()).font(.system(size: 10, weight: .semibold)).tracking(1).foregroundStyle(Theme.textFaint)
78+
}
79+
}
80+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import SwiftUI
2+
3+
// MARK: - Color from hex
4+
extension Color {
5+
init(hex: String) {
6+
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
7+
var v: UInt64 = 0
8+
Scanner(string: s).scanHexInt64(&v)
9+
let r, g, b, a: Double
10+
switch s.count {
11+
case 8: r = Double((v >> 24) & 0xFF)/255; g = Double((v >> 16) & 0xFF)/255; b = Double((v >> 8) & 0xFF)/255; a = Double(v & 0xFF)/255
12+
default: r = Double((v >> 16) & 0xFF)/255; g = Double((v >> 8) & 0xFF)/255; b = Double(v & 0xFF)/255; a = 1
13+
}
14+
self.init(.sRGB, red: r, green: g, blue: b, opacity: a)
15+
}
16+
}
17+
18+
// MARK: - Brand theme
19+
enum Theme {
20+
static let bg = Color(hex: "#0A0A0D")
21+
static let bgElevated = Color(hex: "#101015")
22+
static let surface = Color(hex: "#16161D")
23+
static let surface2 = Color(hex: "#1E1E27")
24+
static let stroke = Color.white.opacity(0.07)
25+
static let primary = Color(hex: "#14B8A6") // OpenProgression teal
26+
static let primaryDim = Color(hex: "#0E7C70")
27+
static let text = Color(hex: "#F5F5F7")
28+
static let textDim = Color(hex: "#9A9AA6")
29+
static let textFaint = Color(hex: "#6A6A76")
30+
31+
// 7-level gradient (Beginner -> Rx). Falls back if data omits a color.
32+
static let levelColors: [Color] = ["#4ADE80","#22C55E","#EAB308","#F97316","#EF4444","#DC2626","#991B1B"].map { Color(hex: $0) }
33+
static func levelColor(_ number: Int) -> Color {
34+
let i = max(1, min(7, number)) - 1
35+
return levelColors[i]
36+
}
37+
38+
static var levelGradient: LinearGradient {
39+
LinearGradient(colors: levelColors, startPoint: .leading, endPoint: .trailing)
40+
}
41+
}
42+
43+
// MARK: - Typography (SF Pro, premium weights). Rounded for display/brand.
44+
extension Font {
45+
static func display(_ size: CGFloat, _ weight: Weight = .heavy) -> Font {
46+
.system(size: size, weight: weight, design: .rounded)
47+
}
48+
static func brand(_ size: CGFloat) -> Font { .system(size: size, weight: .black, design: .rounded) }
49+
}
50+
51+
// MARK: - Reusable view modifiers
52+
struct CardStyle: ViewModifier {
53+
var padding: CGFloat = 18
54+
func body(content: Content) -> some View {
55+
content
56+
.padding(padding)
57+
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
58+
.overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).strokeBorder(Theme.stroke, lineWidth: 1))
59+
}
60+
}
61+
extension View {
62+
func card(padding: CGFloat = 18) -> some View { modifier(CardStyle(padding: padding)) }
63+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import SwiftUI
2+
3+
struct BenchmarksView: View {
4+
@Environment(DataStore.self) private var store
5+
@State private var gender: Gender = .male
6+
7+
private let catOrder = ["squatting","pulling","pressing","olympic_lifting","gymnastics","monostructural","bodyweight","endurance"]
8+
9+
var body: some View {
10+
ScrollView {
11+
VStack(alignment: .leading, spacing: 16) {
12+
VStack(alignment: .leading, spacing: 6) {
13+
SectionLabel(text: "The Standard")
14+
Text("Benchmarks").font(.display(28)).foregroundStyle(Theme.text)
15+
Picker("", selection: $gender) { Text("Male").tag(Gender.male); Text("Female").tag(Gender.female) }
16+
.pickerStyle(.segmented).padding(.top, 2)
17+
}
18+
ForEach(catOrder, id: \.self) { catId in
19+
if let cat = store.categories.first(where: { $0.id == catId }),
20+
let bms = store.benchmarksByCategory[catId] {
21+
VStack(alignment: .leading, spacing: 10) {
22+
HStack(spacing: 8) {
23+
Text(cat.icon)
24+
Text(cat.name).font(.system(size: 16, weight: .bold)).foregroundStyle(Theme.text)
25+
}
26+
ForEach(bms) { b in benchmarkRow(b) }
27+
}.frame(maxWidth: .infinity, alignment: .leading).card(padding: 16)
28+
}
29+
}
30+
Spacer(minLength: 24)
31+
}
32+
.padding(.horizontal, 18).padding(.top, 8)
33+
}
34+
.background(Theme.bg.ignoresSafeArea())
35+
}
36+
37+
private func benchmarkRow(_ b: Benchmark) -> some View {
38+
let rx = b.standards["rx"]?[gender.rawValue]
39+
return HStack {
40+
Text(b.name).font(.system(size: 14, weight: .medium)).foregroundStyle(Theme.text)
41+
Spacer()
42+
HStack(spacing: 6) {
43+
Text("Rx").font(.system(size: 11, weight: .bold)).foregroundStyle(Theme.levelColor(7))
44+
Text(rxText(rx, b.unit)).font(.system(size: 14, weight: .semibold)).foregroundStyle(Theme.textDim)
45+
}
46+
}
47+
.padding(.vertical, 7)
48+
.overlay(alignment: .bottom) { Rectangle().fill(Theme.stroke).frame(height: 1) }
49+
}
50+
51+
private func rxText(_ v: StandardValue?, _ unit: String) -> String {
52+
guard let v else { return "-" }
53+
switch v {
54+
case .scalar(let d):
55+
if unit == "seconds" { let s = Int(d); return String(format: "%d:%02d", s/60, s%60) }
56+
return "\(Int(d)) \(unit == "kg" ? "kg" : unit == "rounds" ? "rds" : unit)"
57+
case .range(let lo, _): return "\(lo)+ \(unit)"
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)