Skip to content

Commit f6d9747

Browse files
OneMuppetclaude
andcommitted
iOS: results log + CSV export, full benchmark detail, swipe-to-change-day
Addresses three pieces of feedback: - Results LOG (local, SwiftData): log a metcon or benchmark result from the session/benchmark screens; history list with swipe-to-delete; export the whole log to a CSV via the share sheet (Save to Files). No backend, all on-device. - Benchmarks are now tappable -> detail sheet showing ALL 7 levels (not just Rx), color-coded, with bodyweight multipliers and a "Log a test" action. - Program: native swipe left/right to change day (plus the week strip + arrows). - Tab bar reworked to 5: Today, Program, Log, Calculator, Standard (Benchmarks + Levels combined under a segmented control). Builds clean (xcodebuild); 0 em dashes; logging verified end-to-end in the simulator (persist + render + export button). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2ebd373 commit f6d9747

10 files changed

Lines changed: 317 additions & 23 deletions

File tree

ios/OpenProgression/App/OpenProgressionApp.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import SwiftData
23

34
@main
45
struct OpenProgressionApp: App {
@@ -10,5 +11,6 @@ struct OpenProgressionApp: App {
1011
.preferredColorScheme(.dark)
1112
.tint(Theme.primary)
1213
}
14+
.modelContainer(for: LogEntry.self)
1315
}
1416
}

ios/OpenProgression/App/RootView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ struct RootView: View {
44
@State private var tab = RootView.initialTab
55
static var initialTab: Int {
66
switch ProcessInfo.processInfo.environment["OP_TAB"] {
7-
case "program": return 1; case "calculator": return 2; case "benchmarks": return 3; case "levels": return 4; default: return 0
7+
case "program": return 1; case "log": return 2; case "calculator": return 3; case "standard": return 4; default: return 0
88
}
99
}
1010
var body: some View {
1111
TabView(selection: $tab) {
1212
TodayView().tag(0).tabItem { Label("Today", systemImage: "bolt.fill") }
1313
ProgramView().tag(1).tabItem { Label("Program", systemImage: "calendar") }
14-
CalculatorView().tag(2).tabItem { Label("Calculator", systemImage: "slider.horizontal.3") }
15-
BenchmarksView().tag(3).tabItem { Label("Benchmarks", systemImage: "chart.bar.fill") }
16-
LevelsView().tag(4).tabItem { Label("Levels", systemImage: "square.stack.3d.up.fill") }
14+
LogView().tag(2).tabItem { Label("Log", systemImage: "checklist") }
15+
CalculatorView().tag(3).tabItem { Label("Calculator", systemImage: "slider.horizontal.3") }
16+
StandardView().tag(4).tabItem { Label("Standard", systemImage: "chart.bar.fill") }
1717
}
1818
.tint(Theme.primary)
1919
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Foundation
2+
import SwiftData
3+
4+
@Model
5+
final class LogEntry {
6+
var date: Date
7+
var type: String // "Metcon" | "Strength" | "Benchmark"
8+
var name: String
9+
var code: String?
10+
var level: Int
11+
var gender: String
12+
var result: String // e.g. "8:32", "120 kg", "12 rounds + 5"
13+
var notes: String?
14+
15+
init(date: Date = Date(), type: String, name: String, code: String? = nil,
16+
level: Int, gender: String, result: String, notes: String? = nil) {
17+
self.date = date; self.type = type; self.name = name; self.code = code
18+
self.level = level; self.gender = gender; self.result = result; self.notes = notes
19+
}
20+
}
21+
22+
enum CSV {
23+
static func escape(_ s: String) -> String {
24+
if s.contains(",") || s.contains("\"") || s.contains("\n") {
25+
return "\"" + s.replacingOccurrences(of: "\"", with: "\"\"") + "\""
26+
}
27+
return s
28+
}
29+
static func file(for entries: [LogEntry]) -> URL {
30+
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd HH:mm"
31+
var rows = ["Date,Type,Name,Code,Level,Gender,Result,Notes"]
32+
let levelName = ["Beginner","Beginner+","Intermediate","Intermediate+","Advanced","Advanced+","Rx"]
33+
for e in entries {
34+
let lvl = (e.level >= 1 && e.level <= 7) ? levelName[e.level-1] : "\(e.level)"
35+
rows.append([df.string(from: e.date), e.type, e.name, e.code ?? "", lvl, e.gender, e.result, e.notes ?? ""].map(escape).joined(separator: ","))
36+
}
37+
let url = FileManager.default.temporaryDirectory.appendingPathComponent("openprogression-log.csv")
38+
try? rows.joined(separator: "\n").data(using: .utf8)?.write(to: url)
39+
return url
40+
}
41+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import SwiftUI
2+
3+
func benchmarkValueText(_ v: StandardValue?, _ unit: String) -> String {
4+
guard let v else { return "-" }
5+
switch v {
6+
case .scalar(let d):
7+
if unit == "seconds" { let s = Int(d); return String(format: "%d:%02d", s/60, s%60) }
8+
return "\(Int(d)) \(unit == "kg" ? "kg" : unit == "rounds" ? "rds" : unit)"
9+
case .range(let lo, let hi):
10+
if hi >= 99 { return "\(lo)+ \(unit)" }
11+
if lo == hi { return "\(lo) \(unit)" }
12+
return "\(lo)-\(hi) \(unit)"
13+
}
14+
}
15+
16+
struct BenchmarkDetailView: View {
17+
@Environment(DataStore.self) private var store
18+
@AppStorage("op.gender") private var gender: Gender = .male
19+
@AppStorage("op.level") private var userLevel: Int = 3
20+
let benchmark: Benchmark
21+
let categoryName: String
22+
@State private var showLog = false
23+
24+
var body: some View {
25+
ScrollView {
26+
VStack(alignment: .leading, spacing: 18) {
27+
VStack(alignment: .leading, spacing: 6) {
28+
SectionLabel(text: categoryName)
29+
Text(benchmark.name).font(.display(28)).foregroundStyle(Theme.text)
30+
HStack(spacing: 8) {
31+
Chip(text: testLabel, color: Theme.primary)
32+
if let d = benchmark.description { Text(d).font(.body(12)).foregroundStyle(Theme.textFaint) }
33+
}
34+
}
35+
Picker("", selection: $gender) { Text("Male").tag(Gender.male); Text("Female").tag(Gender.female) }.pickerStyle(.segmented)
36+
37+
VStack(alignment: .leading, spacing: 6) {
38+
SectionLabel(text: "Standards by level")
39+
ForEach(store.levels.reversed()) { lv in
40+
HStack(spacing: 12) {
41+
Circle().fill(Theme.levelColor(lv.number)).frame(width: 8, height: 8)
42+
Text(lv.name).font(.body(14, .medium)).foregroundStyle(Theme.text)
43+
Spacer()
44+
if let bwm = benchmark.bwMultiplier?[lv.id]?[gender.rawValue] {
45+
Text(String(format: "%.2gx BW", bwm)).font(.body(11)).foregroundStyle(Theme.textFaint)
46+
}
47+
Text(benchmarkValueText(benchmark.standards[lv.id]?[gender.rawValue], benchmark.unit))
48+
.font(.body(15, .semibold)).foregroundStyle(Theme.levelColor(lv.number))
49+
}
50+
.padding(.vertical, 8)
51+
.overlay(alignment: .bottom) { Rectangle().fill(Theme.stroke).frame(height: 1) }
52+
}
53+
}.card(padding: 16)
54+
55+
Button { Haptics.tap(); showLog = true } label: {
56+
Label("Log a test", systemImage: "square.and.pencil")
57+
.font(.body(15, .semibold)).foregroundStyle(Color.black)
58+
.frame(maxWidth: .infinity).padding(.vertical, 14)
59+
.background(Theme.primary, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
60+
}.buttonStyle(.plain)
61+
Spacer(minLength: 12)
62+
}
63+
.padding(20)
64+
}
65+
.background(Theme.bg.ignoresSafeArea())
66+
.sheet(isPresented: $showLog) {
67+
LogEntrySheet(type: "Benchmark", name: benchmark.name, code: nil, level: userLevel, gender: gender,
68+
resultPlaceholder: placeholder).presentationDetents([.medium])
69+
}
70+
}
71+
72+
private var testLabel: String {
73+
switch benchmark.testType { case "1rm": return "1 Rep Max"; case "max_reps": return "Max Reps"; case "time": return "For Time"; case "amrap": return "AMRAP"; default: return benchmark.testType }
74+
}
75+
private var placeholder: String {
76+
switch benchmark.testType { case "time": return "e.g. 7:30"; case "1rm": return "e.g. 120 kg"; default: return benchmark.unit == "reps" ? "e.g. 12 reps" : "e.g. \(benchmark.unit)" }
77+
}
78+
}
Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import SwiftUI
22

3+
private struct SelectedBenchmark: Identifiable {
4+
let benchmark: Benchmark; let category: String
5+
var id: String { benchmark.movement }
6+
}
7+
38
struct BenchmarksView: View {
49
@Environment(DataStore.self) private var store
5-
@State private var gender: Gender = .male
10+
@AppStorage("op.gender") private var gender: Gender = .male
11+
@State private var selected: SelectedBenchmark?
612

713
private let catOrder = ["squatting","pulling","pressing","olympic_lifting","gymnastics","monostructural","bodyweight","endurance"]
814

@@ -18,12 +24,16 @@ struct BenchmarksView: View {
1824
ForEach(catOrder, id: \.self) { catId in
1925
if let cat = store.categories.first(where: { $0.id == catId }),
2026
let bms = store.benchmarksByCategory[catId] {
21-
VStack(alignment: .leading, spacing: 10) {
27+
VStack(alignment: .leading, spacing: 4) {
2228
HStack(spacing: 8) {
2329
Text(cat.icon)
24-
Text(cat.name).font(.system(size: 16, weight: .bold)).foregroundStyle(Theme.text)
30+
Text(cat.name).font(.display(16, .bold)).foregroundStyle(Theme.text)
31+
}.padding(.bottom, 6)
32+
ForEach(bms) { b in
33+
Button { Haptics.tap(); selected = SelectedBenchmark(benchmark: b, category: cat.name) } label: {
34+
benchmarkRow(b)
35+
}.buttonStyle(.plain)
2536
}
26-
ForEach(bms) { b in benchmarkRow(b) }
2737
}.frame(maxWidth: .infinity, alignment: .leading).card(padding: 16)
2838
}
2939
}
@@ -32,29 +42,22 @@ struct BenchmarksView: View {
3242
.padding(.horizontal, 18).padding(.top, 8)
3343
}
3444
.background(Theme.bg.ignoresSafeArea())
45+
.sheet(item: $selected) { sel in
46+
BenchmarkDetailView(benchmark: sel.benchmark, categoryName: sel.category).presentationDragIndicator(.visible)
47+
}
3548
}
3649

3750
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)
51+
HStack {
52+
Text(b.name).font(.body(14, .medium)).foregroundStyle(Theme.text)
4153
Spacer()
4254
HStack(spacing: 6) {
4355
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)
56+
Text(benchmarkValueText(b.standards["rx"]?[gender.rawValue], b.unit)).font(.body(14, .semibold)).foregroundStyle(Theme.textDim)
57+
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold)).foregroundStyle(Theme.textFaint)
4558
}
4659
}
47-
.padding(.vertical, 7)
60+
.padding(.vertical, 8)
4861
.overlay(alignment: .bottom) { Rectangle().fill(Theme.stroke).frame(height: 1) }
4962
}
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-
}
6063
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import SwiftUI
2+
import SwiftData
3+
4+
let levelShort = ["BEG","BEG+","INT","INT+","ADV","ADV+","RX"]
5+
6+
struct LogEntrySheet: View {
7+
@Environment(\.modelContext) private var ctx
8+
@Environment(\.dismiss) private var dismiss
9+
let type: String
10+
let name: String
11+
let code: String?
12+
let level: Int
13+
let gender: Gender
14+
let resultPlaceholder: String
15+
@State private var result = ""
16+
@State private var notes = ""
17+
18+
var body: some View {
19+
NavigationStack {
20+
ScrollView {
21+
VStack(alignment: .leading, spacing: 18) {
22+
VStack(alignment: .leading, spacing: 6) {
23+
SectionLabel(text: "Log \(type)")
24+
Text(name).font(.display(24)).foregroundStyle(Theme.text)
25+
HStack(spacing: 8) {
26+
if let c = code { Chip(text: c, color: Theme.textDim) }
27+
if level >= 1 && level <= 7 { LevelPill(name: levelShort[level-1], number: level) }
28+
Chip(text: gender.rawValue.capitalized, color: Theme.textDim)
29+
}
30+
}
31+
field("Result", placeholder: resultPlaceholder, text: $result)
32+
field("Notes (optional)", placeholder: "How it felt, scaling used...", text: $notes)
33+
Spacer(minLength: 8)
34+
}
35+
.padding(20)
36+
}
37+
.background(Theme.bg.ignoresSafeArea())
38+
.toolbar {
39+
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.tint(Theme.textDim) }
40+
ToolbarItem(placement: .confirmationAction) {
41+
Button("Save") { save() }.bold().disabled(result.trimmingCharacters(in: .whitespaces).isEmpty)
42+
}
43+
}
44+
}
45+
}
46+
47+
private func field(_ label: String, placeholder: String, text: Binding<String>) -> some View {
48+
VStack(alignment: .leading, spacing: 8) {
49+
SectionLabel(text: label)
50+
TextField(placeholder, text: text, axis: label.hasPrefix("Notes") ? .vertical : .horizontal)
51+
.lineLimit(label.hasPrefix("Notes") ? 4 : 1)
52+
.font(.body(16, .medium)).foregroundStyle(Theme.text)
53+
.padding(14)
54+
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
55+
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(Theme.stroke))
56+
}
57+
}
58+
59+
private func save() {
60+
Haptics.tap()
61+
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
62+
ctx.insert(LogEntry(type: type, name: name, code: code, level: level,
63+
gender: gender.rawValue, result: result.trimmingCharacters(in: .whitespaces),
64+
notes: trimmedNotes.isEmpty ? nil : trimmedNotes))
65+
dismiss()
66+
}
67+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import SwiftUI
2+
import SwiftData
3+
4+
struct LogView: View {
5+
@Environment(\.modelContext) private var ctx
6+
@Query(sort: \LogEntry.date, order: .reverse) private var entries: [LogEntry]
7+
8+
var body: some View {
9+
NavigationStack {
10+
Group {
11+
if entries.isEmpty { empty } else { list }
12+
}
13+
.background(Theme.bg.ignoresSafeArea())
14+
.navigationTitle("Log")
15+
.toolbar {
16+
if !entries.isEmpty {
17+
ToolbarItem(placement: .topBarTrailing) {
18+
ShareLink(item: CSV.file(for: entries)) {
19+
Image(systemName: "square.and.arrow.up")
20+
}.tint(Theme.primary)
21+
}
22+
}
23+
}
24+
}
25+
}
26+
27+
private var empty: some View {
28+
VStack(spacing: 12) {
29+
Image(systemName: "checklist").font(.system(size: 44, weight: .light)).foregroundStyle(Theme.textFaint)
30+
Text("No results yet").font(.display(20)).foregroundStyle(Theme.text)
31+
Text("Log a workout or a benchmark and it shows up here. Export the whole log to a CSV anytime.")
32+
.font(.body(14)).foregroundStyle(Theme.textDim).multilineTextAlignment(.center).padding(.horizontal, 40)
33+
}.frame(maxWidth: .infinity, maxHeight: .infinity)
34+
}
35+
36+
private var list: some View {
37+
List {
38+
ForEach(entries) { e in row(e) }
39+
.onDelete { idx in idx.map { entries[$0] }.forEach(ctx.delete) }
40+
.listRowBackground(Theme.surface)
41+
.listRowSeparatorTint(Theme.stroke)
42+
}
43+
.listStyle(.plain)
44+
.scrollContentBackground(.hidden)
45+
}
46+
47+
private func row(_ e: LogEntry) -> some View {
48+
HStack(spacing: 12) {
49+
Circle().fill(Theme.levelColor(e.level)).frame(width: 8, height: 8)
50+
VStack(alignment: .leading, spacing: 2) {
51+
Text(e.name).font(.body(15, .semibold)).foregroundStyle(Theme.text)
52+
Text("\(e.type) · \(prettyDate(e.date))\(e.notes.map { " · \($0)" } ?? "")")
53+
.font(.body(12)).foregroundStyle(Theme.textFaint).lineLimit(1)
54+
}
55+
Spacer()
56+
Text(e.result).font(.display(16, .bold)).foregroundStyle(Theme.primary)
57+
}.padding(.vertical, 4)
58+
}
59+
60+
private func prettyDate(_ d: Date) -> String {
61+
let f = DateFormatter(); f.dateFormat = "MMM d"; return f.string(from: d)
62+
}
63+
}

ios/OpenProgression/Features/Program/ProgramView.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ struct ProgramView: View {
3636
.padding(.horizontal, 18).padding(.top, 8)
3737
}
3838
.background(Theme.bg.ignoresSafeArea())
39+
.simultaneousGesture(
40+
DragGesture(minimumDistance: 24).onEnded { v in
41+
guard abs(v.translation.width) > 60, abs(v.translation.width) > abs(v.translation.height) * 1.4 else { return }
42+
Haptics.select()
43+
withAnimation(.snappy) { shift(v.translation.width < 0 ? 1 : -1) }
44+
}
45+
)
3946
}
4047

4148
private var header: some View {

0 commit comments

Comments
 (0)