11import SwiftUI
22import SwiftData
3+ import Charts
34
45struct LogView : View {
56 @Environment ( \. modelContext) private var ctx
@@ -8,16 +9,14 @@ struct LogView: View {
89 var body : some View {
910 NavigationStack {
1011 Group {
11- if entries. isEmpty { empty } else { list }
12+ if entries. isEmpty { empty } else { content }
1213 }
1314 . background ( Theme . bg. ignoresSafeArea ( ) )
1415 . navigationTitle ( " Log " )
1516 . toolbar {
1617 if !entries. isEmpty {
1718 ToolbarItem ( placement: . topBarTrailing) {
18- ShareLink ( item: CSV . file ( for: entries) ) {
19- Image ( systemName: " square.and.arrow.up " )
20- } . tint ( Theme . primary)
19+ ShareLink ( item: CSV . file ( for: entries) ) { Image ( systemName: " square.and.arrow.up " ) } . tint ( Theme . primary)
2120 }
2221 }
2322 }
@@ -28,36 +27,127 @@ struct LogView: View {
2827 VStack ( spacing: 12 ) {
2928 Image ( systemName: " checklist " ) . font ( . system( size: 44 , weight: . light) ) . foregroundStyle ( Theme . textFaint)
3029 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 ." )
30+ Text ( " Run a workout or log a benchmark and it shows up here, with PRs, a streak, and CSV export ." )
3231 . font ( . body( 14 ) ) . foregroundStyle ( Theme . textDim) . multilineTextAlignment ( . center) . padding ( . horizontal, 40 )
3332 } . frame ( maxWidth: . infinity, maxHeight: . infinity)
3433 }
3534
36- private var list : some View {
35+ private var content : some View {
3736 List {
38- ForEach ( entries) { e in row ( e) }
37+ Section {
38+ statsCard. listRowInsets ( EdgeInsets ( top: 8 , leading: 18 , bottom: 6 , trailing: 18 ) )
39+ . listRowBackground ( Color . clear) . listRowSeparator ( . hidden)
40+ }
41+ Section {
42+ ForEach ( entries) { e in
43+ row ( e) . listRowInsets ( EdgeInsets ( top: 0 , leading: 18 , bottom: 0 , trailing: 18 ) )
44+ . listRowBackground ( Color . clear) . listRowSeparator ( . hidden)
45+ }
3946 . onDelete { idx in idx. map { entries [ $0] } . forEach ( ctx. delete) }
40- . listRowBackground ( Theme . surface)
41- . listRowSeparatorTint ( Theme . stroke)
47+ }
4248 }
4349 . listStyle ( . plain)
4450 . scrollContentBackground ( . hidden)
4551 }
4652
53+ // MARK: stats
54+ private var statsCard : some View {
55+ VStack ( alignment: . leading, spacing: 14 ) {
56+ HStack {
57+ stat ( " \( streak) " , streak == 1 ? " day streak " : " day streak " )
58+ Divider ( ) . frame ( height: 34 ) . overlay ( Theme . stroke)
59+ stat ( " \( entries. count) " , " logged " )
60+ Divider ( ) . frame ( height: 34 ) . overlay ( Theme . stroke)
61+ stat ( " \( thisWeekCount) " , " this week " )
62+ }
63+ if weekCounts. contains ( where: { $0. 1 > 0 } ) {
64+ Chart ( weekCounts, id: \. 0 ) { item in
65+ BarMark ( x: . value( " Week " , item. 0 , unit: . weekOfYear) , y: . value( " Sessions " , item. 1 ) )
66+ . foregroundStyle ( Theme . primary) . cornerRadius ( 3 )
67+ }
68+ . frame ( height: 70 )
69+ . chartXAxis { AxisMarks ( values: . stride( by: . weekOfYear) ) { _ in } }
70+ . chartYAxis ( . hidden)
71+ }
72+ } . frame ( maxWidth: . infinity) . card ( )
73+ }
74+ private func stat( _ v: String , _ l: String ) -> some View {
75+ VStack ( spacing: 2 ) {
76+ Text ( v) . font ( . display( 22 , . bold) ) . foregroundStyle ( Theme . text)
77+ Text ( l. uppercased ( ) ) . font ( . system( size: 10 , weight: . bold) ) . tracking ( 0.8 ) . foregroundStyle ( Theme . textFaint)
78+ } . frame ( maxWidth: . infinity)
79+ }
80+
4781 private func row( _ e: LogEntry ) -> some View {
4882 HStack ( spacing: 12 ) {
4983 Circle ( ) . fill ( Theme . levelColor ( e. level) ) . frame ( width: 8 , height: 8 )
5084 VStack ( alignment: . leading, spacing: 2 ) {
51- Text ( e. name) . font ( . body( 15 , . semibold) ) . foregroundStyle ( Theme . text)
85+ HStack ( spacing: 6 ) {
86+ Text ( e. name) . font ( . body( 15 , . semibold) ) . foregroundStyle ( Theme . text)
87+ if prIDs. contains ( e. persistentModelID) {
88+ Text ( " PR " ) . font ( . system( size: 9 , weight: . black) ) . foregroundStyle ( . black)
89+ . padding ( . horizontal, 5 ) . padding ( . vertical, 2 ) . background ( Theme . primary, in: Capsule ( ) )
90+ }
91+ }
5292 Text ( " \( e. type) · \( prettyDate ( e. date) ) \( e. notes. map { " · \( $0) " } ?? " " ) " )
5393 . font ( . body( 12 ) ) . foregroundStyle ( Theme . textFaint) . lineLimit ( 1 )
5494 }
5595 Spacer ( )
5696 Text ( e. result) . font ( . display( 16 , . bold) ) . foregroundStyle ( Theme . primary)
57- } . padding ( . vertical, 4 )
97+ }
98+ . padding ( . vertical, 11 )
99+ . overlay ( alignment: . bottom) { Rectangle ( ) . fill ( Theme . stroke) . frame ( height: 1 ) }
58100 }
59101
60- private func prettyDate( _ d: Date ) -> String {
61- let f = DateFormatter ( ) ; f. dateFormat = " MMM d " ; return f. string ( from: d)
102+ // MARK: derived
103+ private var cal : Calendar { Calendar . current }
104+ private func day( _ d: Date ) -> Date { cal. startOfDay ( for: d) }
105+ private func week( _ d: Date ) -> Date { cal. date ( from: cal. dateComponents ( [ . yearForWeekOfYear, . weekOfYear] , from: d) ) ?? d }
106+
107+ private var streak : Int {
108+ let days = Set ( entries. map { day ( $0. date) } )
109+ guard !days. isEmpty else { return 0 }
110+ var cursor = day ( Date ( ) )
111+ if !days. contains ( cursor) { cursor = cal. date ( byAdding: . day, value: - 1 , to: cursor) ! } // grace for "not yet today"
112+ guard days. contains ( cursor) else { return 0 }
113+ var n = 0
114+ while days. contains ( cursor) { n += 1 ; cursor = cal. date ( byAdding: . day, value: - 1 , to: cursor) ! }
115+ return n
116+ }
117+ private var thisWeekCount : Int { entries. filter { week ( $0. date) == week ( Date ( ) ) } . count }
118+ private var weekCounts : [ ( Date , Int ) ] {
119+ let now = week ( Date ( ) )
120+ return ( 0 ..< 8 ) . reversed ( ) . map { off -> ( Date , Int ) in
121+ let w = cal. date ( byAdding: . weekOfYear, value: - off, to: now) ?? now
122+ return ( w, entries. filter { week ( $0. date) == w } . count)
123+ }
124+ }
125+
126+ /// IDs of the best entry per movement (only when there is a prior to beat).
127+ private var prIDs : Set < PersistentIdentifier > {
128+ var counts : [ String : Int ] = [ : ]
129+ for e in entries { counts [ e. name, default: 0 ] += 1 }
130+ var best : [ String : LogEntry ] = [ : ]
131+ for e in entries where ( counts [ e. name] ?? 0 ) >= 2 {
132+ if let b = best [ e. name] { if better ( e, than: b) { best [ e. name] = e } }
133+ else { best [ e. name] = e }
134+ }
135+ return Set ( best. values. map { $0. persistentModelID } )
62136 }
137+ private func score( _ r: String ) -> ( Double , Bool ) {
138+ if r. contains ( " : " ) {
139+ let parts = r. split ( separator: " : " )
140+ if parts. count == 2 , let m = Double ( parts [ 0 ] ) , let s = Double ( parts [ 1 ] . prefix ( while: { $0. isNumber } ) ) {
141+ return ( m * 60 + s, true ) // time, lower is better
142+ }
143+ }
144+ let num = Double ( r. prefix ( while: { $0. isNumber || $0 == " . " } ) ) ?? 0
145+ return ( num, false ) // load/reps/rounds, higher is better
146+ }
147+ private func better( _ a: LogEntry , than b: LogEntry ) -> Bool {
148+ let ( av, low) = score ( a. result) ; let ( bv, _) = score ( b. result)
149+ return low ? av < bv : av > bv
150+ }
151+
152+ private func prettyDate( _ d: Date ) -> String { let f = DateFormatter ( ) ; f. dateFormat = " MMM d " ; return f. string ( from: d) }
63153}
0 commit comments