/ BalanceKit / WorkoutDashboardView.swift
WorkoutDashboardView.swift
  1  //
  2  //  WorkoutDashboardView.swift
  3  //  BalanceKit
  4  //
  5  //  Created by Alexander Kunau on 02.11.24.
  6  //
  7  
  8  import SwiftUI
  9  import Charts
 10  
 11  struct WorkoutDashboardView: View {
 12      @ObservedObject var workoutManager: WorkoutManager
 13      @State private var selectedPeriod: TimePeriod = .week
 14      @State private var selectedMonth = Date()
 15      
 16      enum TimePeriod: String, CaseIterable {
 17          case week = "Woche"
 18          case month = "Monat"
 19      }
 20      
 21      var body: some View {
 22          ScrollView {
 23              VStack(spacing: 20) {
 24                  // Übersicht Metriken
 25                  overviewSection
 26                  
 27                  // Wertungs-Chart
 28                  chartSection
 29                  
 30                  // Verlaufs-Chart
 31                  frequencyChartSection
 32                  
 33                  // Kalender
 34                  calendarSection
 35                  
 36                  // Beste Serien
 37                  bestStreaksSection
 38                  
 39                  // Häufigkeit Heatmap
 40                  frequencyHeatmapSection
 41              }
 42              .padding()
 43          }
 44          .navigationTitle("Workout")
 45          .navigationBarTitleDisplayMode(.inline)
 46      }
 47      
 48      // MARK: - Übersicht Section
 49      private var overviewSection: some View {
 50          VStack(spacing: 16) {
 51              Text("Übersicht")
 52                  .font(.headline)
 53                  .frame(maxWidth: .infinity, alignment: .leading)
 54              
 55              HStack(spacing: 16) {
 56                  // Progress Ring
 57                  ZStack {
 58                      Circle()
 59                          .stroke(lineWidth: 12)
 60                          .opacity(0.2)
 61                          .foregroundColor(.green)
 62                      
 63                      Circle()
 64                          .trim(from: 0.0, to: CGFloat(min(weeklyProgress, 1.0)))
 65                          .stroke(style: StrokeStyle(lineWidth: 12, lineCap: .round))
 66                          .foregroundColor(.green)
 67                          .rotationEffect(Angle(degrees: -90))
 68                      
 69                      VStack {
 70                          Text("\(Int(weeklyProgress * 100))%")
 71                              .font(.title2)
 72                              .fontWeight(.bold)
 73                          Text("Wertung")
 74                              .font(.caption)
 75                              .foregroundColor(.secondary)
 76                      }
 77                  }
 78                  .frame(width: 100, height: 100)
 79                  
 80                  VStack(spacing: 12) {
 81                      // Monat
 82                      VStack(alignment: .leading) {
 83                          Text("Monat")
 84                              .font(.caption)
 85                              .foregroundColor(.secondary)
 86                          Text("\(monthlyChange >= 0 ? "+" : "")\(monthlyChange)%")
 87                              .font(.title3)
 88                              .fontWeight(.bold)
 89                              .foregroundColor(monthlyChange >= 0 ? .green : .red)
 90                      }
 91                      .frame(maxWidth: .infinity, alignment: .leading)
 92                      
 93                      // Jahr
 94                      VStack(alignment: .leading) {
 95                          Text("Jahr")
 96                              .font(.caption)
 97                              .foregroundColor(.secondary)
 98                          Text("\(yearlyChange >= 0 ? "+" : "")\(yearlyChange)%")
 99                              .font(.title3)
100                              .fontWeight(.bold)
101                              .foregroundColor(yearlyChange >= 0 ? .green : .red)
102                      }
103                      .frame(maxWidth: .infinity, alignment: .leading)
104                  }
105                  
106                  // Insgesamt
107                  VStack(alignment: .trailing, spacing: 4) {
108                      Text("\(totalWorkouts)")
109                          .font(.system(size: 36, weight: .bold))
110                      Text("Insgesamt")
111                          .font(.caption)
112                          .foregroundColor(.secondary)
113                  }
114              }
115              .padding()
116              .background(Color(.secondarySystemGroupedBackground))
117              .cornerRadius(16)
118          }
119      }
120      
121      // MARK: - Chart Section (Wertung)
122      private var chartSection: some View {
123          VStack(spacing: 12) {
124              HStack {
125                  Text("Wertung")
126                      .font(.headline)
127                  Spacer()
128                  Picker("Zeitraum", selection: $selectedPeriod) {
129                      ForEach(TimePeriod.allCases, id: \.self) { period in
130                          Text(period.rawValue).tag(period)
131                      }
132                  }
133                  .pickerStyle(.menu)
134              }
135              
136              Chart {
137                  ForEach(chartData, id: \.date) { data in
138                      LineMark(
139                          x: .value("Datum", data.date),
140                          y: .value("Prozent", data.percentage)
141                      )
142                      .foregroundStyle(.green)
143                      .interpolationMethod(.catmullRom)
144                      
145                      PointMark(
146                          x: .value("Datum", data.date),
147                          y: .value("Prozent", data.percentage)
148                      )
149                      .foregroundStyle(.green)
150                  }
151              }
152              .frame(height: 200)
153              .chartYScale(domain: 0...100)
154              .chartXAxis {
155                  AxisMarks(values: .automatic(desiredCount: 6))
156              }
157              .padding()
158              .background(Color(.secondarySystemGroupedBackground))
159              .cornerRadius(16)
160          }
161      }
162      
163      // MARK: - Frequency Chart Section (Verlauf)
164      private var frequencyChartSection: some View {
165          VStack(spacing: 12) {
166              HStack {
167                  Text("Verlauf")
168                      .font(.headline)
169                  Spacer()
170                  Text("Woche")
171                      .font(.caption)
172                      .foregroundColor(.secondary)
173              }
174              
175              Chart {
176                  ForEach(weeklyFrequencyData, id: \.date) { data in
177                      BarMark(
178                          x: .value("Datum", data.date),
179                          y: .value("Anzahl", data.count)
180                      )
181                      .foregroundStyle(.green)
182                  }
183              }
184              .frame(height: 150)
185              .chartYScale(domain: 0...maxWeeklyCount + 1)
186              .chartXAxis {
187                  AxisMarks(values: .automatic(desiredCount: 8))
188              }
189              .padding()
190              .background(Color(.secondarySystemGroupedBackground))
191              .cornerRadius(16)
192          }
193      }
194      
195      // MARK: - Kalender Section
196      private var calendarSection: some View {
197          VStack(spacing: 12) {
198              Text("Kalender")
199                  .font(.headline)
200                  .frame(maxWidth: .infinity, alignment: .leading)
201              
202              VStack(spacing: 0) {
203                  // Monats-Selector
204                  HStack {
205                      Button(action: { changeMonth(by: -1) }) {
206                          Image(systemName: "chevron.left")
207                      }
208                      
209                      Spacer()
210                      
211                      Text(monthYearString)
212                          .font(.headline)
213                      
214                      Spacer()
215                      
216                      Button(action: { changeMonth(by: 1) }) {
217                          Image(systemName: "chevron.right")
218                      }
219                  }
220                  .padding(.horizontal)
221                  .padding(.top, 16)
222                  .padding(.bottom, 8)
223                  
224                  // Kalender Grid
225                  LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 8) {
226                      // Wochentage
227                      ForEach(Array(["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].enumerated()), id: \.offset) { index, day in
228                          Text(day)
229                              .font(.caption)
230                              .foregroundColor(.secondary)
231                      }
232                      
233                      // Leere Tage am Anfang
234                      ForEach(0..<firstWeekdayOffset, id: \.self) { offset in
235                          Text("")
236                              .frame(width: 36, height: 36)
237                              .id("empty_\(offset)")
238                      }
239                      
240                      // Tage des Monats
241                      ForEach(1...daysInMonth, id: \.self) { day in
242                          let date = dateForDay(day)
243                          let hasWorkout = workoutsOnDate(date) > 0
244                          
245                          Text("\(day)")
246                              .font(.caption)
247                              .frame(width: 36, height: 36)
248                              .background(hasWorkout ? Color.green.opacity(0.7) : Color.clear)
249                              .cornerRadius(8)
250                              .id("day_\(day)")
251                      }
252                  }
253                  .padding()
254              }
255              .background(Color(.secondarySystemGroupedBackground))
256              .cornerRadius(16)
257              
258              Button("BEARBEITEN") {
259                  // Aktion für Bearbeiten
260              }
261              .font(.caption)
262              .foregroundColor(.secondary)
263          }
264      }
265      
266      // MARK: - Beste Serien Section
267      private var bestStreaksSection: some View {
268          VStack(spacing: 12) {
269              Text("Beste Serien")
270                  .font(.headline)
271                  .frame(maxWidth: .infinity, alignment: .leading)
272              
273              VStack(spacing: 8) {
274                  ForEach(bestStreaks, id: \.startDate) { streak in
275                      HStack {
276                          Text(formatDate(streak.startDate))
277                              .font(.caption)
278                              .foregroundColor(.secondary)
279                          
280                          Spacer()
281                          
282                          Text("\(streak.days)")
283                              .font(.headline)
284                              .padding(.horizontal, 16)
285                              .padding(.vertical, 8)
286                              .background(Color.gray.opacity(0.3))
287                              .cornerRadius(8)
288                          
289                          Spacer()
290                          
291                          Text(formatDate(streak.endDate))
292                              .font(.caption)
293                              .foregroundColor(.secondary)
294                      }
295                      .padding(.vertical, 4)
296                  }
297              }
298              .padding()
299              .background(Color(.secondarySystemGroupedBackground))
300              .cornerRadius(16)
301          }
302      }
303      
304      // MARK: - Häufigkeit Heatmap Section
305      private var frequencyHeatmapSection: some View {
306          VStack(spacing: 12) {
307              Text("Häufigkeit")
308                  .font(.headline)
309                  .frame(maxWidth: .infinity, alignment: .leading)
310              
311              VStack(spacing: 4) {
312                  // Wochentage Header
313                  HStack(spacing: 4) {
314                      ForEach(["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"], id: \.self) { day in
315                          Text(day)
316                              .font(.caption2)
317                              .frame(maxWidth: .infinity)
318                      }
319                  }
320                  
321                  // Heatmap Rows
322                  ForEach(0..<weeksToShow, id: \.self) { weekIndex in
323                      HStack(spacing: 4) {
324                          ForEach(0..<7) { dayIndex in
325                              let date = getDateForHeatmap(weekIndex: weekIndex, dayIndex: dayIndex)
326                              let workoutCount = workoutsOnDate(date)
327                              
328                              Circle()
329                                  .fill(workoutCount > 0 ? Color.green.opacity(min(Double(workoutCount) / 3.0, 1.0)) : Color.gray.opacity(0.1))
330                                  .frame(maxWidth: .infinity)
331                                  .aspectRatio(1, contentMode: .fit)
332                                  .padding(2)
333                          }
334                      }
335                  }
336              }
337              .padding()
338              .background(Color(.secondarySystemGroupedBackground))
339              .cornerRadius(16)
340          }
341      }
342      
343      // MARK: - Berechnungen
344      
345      private var weeklyProgress: Double {
346          let thisWeekWorkouts = workoutManager.workouts.filter { workout in
347              Calendar.current.isDate(workout.date, equalTo: Date(), toGranularity: .weekOfYear)
348          }
349          let goal = 3.0 // 3 Workouts pro Woche als Ziel
350          return min(Double(thisWeekWorkouts.count) / goal, 1.0)
351      }
352      
353      private var monthlyChange: Int {
354          let calendar = Calendar.current
355          let now = Date()
356          
357          // Dieser Monat
358          let thisMonthWorkouts = workoutManager.workouts.filter {
359              calendar.isDate($0.date, equalTo: now, toGranularity: .month)
360          }.count
361          
362          // Letzter Monat
363          guard let lastMonth = calendar.date(byAdding: .month, value: -1, to: now) else { return 0 }
364          let lastMonthWorkouts = workoutManager.workouts.filter {
365              calendar.isDate($0.date, equalTo: lastMonth, toGranularity: .month)
366          }.count
367          
368          if lastMonthWorkouts == 0 {
369              return thisMonthWorkouts > 0 ? 100 : 0
370          }
371          
372          return Int((Double(thisMonthWorkouts - lastMonthWorkouts) / Double(lastMonthWorkouts)) * 100)
373      }
374      
375      private var yearlyChange: Int {
376          let calendar = Calendar.current
377          let now = Date()
378          
379          // Dieses Jahr
380          let thisYearWorkouts = workoutManager.workouts.filter {
381              calendar.isDate($0.date, equalTo: now, toGranularity: .year)
382          }.count
383          
384          // Letztes Jahr
385          guard let lastYear = calendar.date(byAdding: .year, value: -1, to: now) else { return 0 }
386          let lastYearWorkouts = workoutManager.workouts.filter {
387              calendar.isDate($0.date, equalTo: lastYear, toGranularity: .year)
388          }.count
389          
390          if lastYearWorkouts == 0 {
391              return thisYearWorkouts > 0 ? 100 : 0
392          }
393          
394          return Int((Double(thisYearWorkouts - lastYearWorkouts) / Double(lastYearWorkouts)) * 100)
395      }
396      
397      private var totalWorkouts: Int {
398          workoutManager.workouts.count
399      }
400      
401      private var chartData: [(date: Date, percentage: Double)] {
402          let calendar = Calendar.current
403          let now = Date()
404          var data: [(date: Date, percentage: Double)] = []
405          
406          let periods = selectedPeriod == .week ? 12 : 12 // 12 Wochen oder 12 Monate
407          let component: Calendar.Component = selectedPeriod == .week ? .weekOfYear : .month
408          
409          for i in (0..<periods).reversed() {
410              guard let date = calendar.date(byAdding: component, value: -i, to: now) else { continue }
411              
412              let workoutsInPeriod = workoutManager.workouts.filter {
413                  calendar.isDate($0.date, equalTo: date, toGranularity: component)
414              }.count
415              
416              let goal = selectedPeriod == .week ? 3.0 : 12.0
417              let percentage = min((Double(workoutsInPeriod) / goal) * 100, 100)
418              
419              data.append((date: date, percentage: percentage))
420          }
421          
422          return data
423      }
424      
425      private var weeklyFrequencyData: [(date: Date, count: Int)] {
426          let calendar = Calendar.current
427          let now = Date()
428          var data: [(date: Date, count: Int)] = []
429          
430          for i in (0..<12).reversed() {
431              guard let weekStart = calendar.date(byAdding: .weekOfYear, value: -i, to: now) else { continue }
432              
433              let workoutsInWeek = workoutManager.workouts.filter {
434                  calendar.isDate($0.date, equalTo: weekStart, toGranularity: .weekOfYear)
435              }.count
436              
437              data.append((date: weekStart, count: workoutsInWeek))
438          }
439          
440          return data
441      }
442      
443      private var maxWeeklyCount: Int {
444          weeklyFrequencyData.map { $0.count }.max() ?? 3
445      }
446      
447      private var bestStreaks: [(startDate: Date, endDate: Date, days: Int)] {
448          let sortedWorkouts = workoutManager.workouts.sorted { $0.date < $1.date }
449          var streaks: [(startDate: Date, endDate: Date, days: Int)] = []
450          
451          if sortedWorkouts.isEmpty { return [] }
452          
453          var currentStreak: (start: Date, end: Date, days: Int) = (sortedWorkouts[0].date, sortedWorkouts[0].date, 1)
454          var previousDate = sortedWorkouts[0].date
455          
456          for workout in sortedWorkouts.dropFirst() {
457              let daysDifference = Calendar.current.dateComponents([.day], from: previousDate, to: workout.date).day ?? 0
458              
459              if daysDifference <= 7 { // Maximal 7 Tage Pause
460                  currentStreak.end = workout.date
461                  currentStreak.days += 1
462              } else {
463                  if currentStreak.days > 1 {
464                      streaks.append((currentStreak.start, currentStreak.end, currentStreak.days))
465                  }
466                  currentStreak = (workout.date, workout.date, 1)
467              }
468              
469              previousDate = workout.date
470          }
471          
472          if currentStreak.days > 1 {
473              streaks.append((currentStreak.start, currentStreak.end, currentStreak.days))
474          }
475          
476          return Array(streaks.sorted { $0.days > $1.days }.prefix(5))
477      }
478      
479      // MARK: - Kalender Helpers
480      
481      private var monthYearString: String {
482          let formatter = DateFormatter()
483          formatter.dateFormat = "MMMM yyyy"
484          formatter.locale = Locale(identifier: "de_DE")
485          return formatter.string(from: selectedMonth)
486      }
487      
488      private var daysInMonth: Int {
489          let calendar = Calendar.current
490          let range = calendar.range(of: .day, in: .month, for: selectedMonth)
491          return range?.count ?? 30
492      }
493      
494      private var firstWeekdayOffset: Int {
495          let calendar = Calendar.current
496          let firstDay = calendar.date(from: calendar.dateComponents([.year, .month], from: selectedMonth))!
497          let weekday = calendar.component(.weekday, from: firstDay)
498          // Montag = 1, Sonntag = 7 (angepasst für Montag-Start)
499          return weekday == 1 ? 6 : weekday - 2
500      }
501      
502      private func dateForDay(_ day: Int) -> Date {
503          let calendar = Calendar.current
504          var components = calendar.dateComponents([.year, .month], from: selectedMonth)
505          components.day = day
506          return calendar.date(from: components) ?? Date()
507      }
508      
509      private func workoutsOnDate(_ date: Date) -> Int {
510          workoutManager.workouts.filter {
511              Calendar.current.isDate($0.date, inSameDayAs: date)
512          }.count
513      }
514      
515      private func changeMonth(by value: Int) {
516          if let newMonth = Calendar.current.date(byAdding: .month, value: value, to: selectedMonth) {
517              selectedMonth = newMonth
518          }
519      }
520      
521      private func formatDate(_ date: Date) -> String {
522          let formatter = DateFormatter()
523          formatter.dateFormat = "dd.MM.yyyy"
524          return formatter.string(from: date)
525      }
526      
527      // MARK: - Heatmap Helpers
528      
529      private var weeksToShow: Int { 6 } // 6 Wochen = ~1.5 Monate
530      
531      private func getDateForHeatmap(weekIndex: Int, dayIndex: Int) -> Date {
532          let calendar = Calendar.current
533          let now = Date()
534          
535          // Gehe zurück zur gewünschten Woche
536          guard let weekStart = calendar.date(byAdding: .weekOfYear, value: -(weeksToShow - weekIndex - 1), to: now) else {
537              return now
538          }
539          
540          // Finde Montag dieser Woche
541          let weekday = calendar.component(.weekday, from: weekStart)
542          let daysToMonday = weekday == 1 ? -6 : 2 - weekday
543          guard let monday = calendar.date(byAdding: .day, value: daysToMonday, to: weekStart) else {
544              return now
545          }
546          
547          // Addiere Tage für den gewünschten Wochentag
548          return calendar.date(byAdding: .day, value: dayIndex, to: monday) ?? now
549      }
550  }
551  
552  #Preview {
553      NavigationStack {
554          WorkoutDashboardView(workoutManager: WorkoutManager())
555      }
556  }