TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SOLVED: Problem with calculation of differences between month-value pair beyond the turn of the year

Forums > Swift

I have a problem with generating data for a chart view in SwiftUI. In my app I have counter data, here is an example of a data series: The data consists of the pair "Date - Value":

30.09.22 - 712
31.10.22 - 716
30.11.22 - 721
31.12.22 - 726
31.01.23 - 730
28.02.23 - 734
31.03.23 - 739
18.04.23 - 742
30.04.23 - 744
31.05.23 - 747
30.06.23 - 749
31.07.23 - 750
31.08.23 - 752
30.9.23 - 754
31.10.23 - 757

From this data, the data for a chart in the form "Month - Value" is now to be generated for a year selected by the user. The value for the month results from the difference from month to month. Based on the above data, the following chart data should be generated should be generated: Year 2022:

10 - 4
11 - 5
12 - 5

Year 2023:

01 - 4
02 - 4
03 - 5
04 - 5
05 - 3
06 -2
07 - 1
08 - 2
09 - 2
10 - 3

From my point of view, the following special features must be taken into account:

  1. one or more entries per month may exist. Only the last entry per month may be used for the difference calculation.
  2. the first month in the entire data, based on the above data, is 09/2022 may not be displayed in the chart, as I cannot calculate a difference for this month.
  3. for the first month of the year (01 - January), the difference must be calculated with the last month of the previous year, as long as there is a previous year. If there is no previous year, then it is the first month in the entire data series and point 2 applies.

Based on the above requirements, I have created the following SwiftUI ChartView. The display in the chart also works so far, except for the problem from point 3 above. In my implementation, the month 01 is not displayed in the above example data series for the year 2023. In my opinion, this is because I have implemented point 2, But without correctly taking point 3 into account.

I have no idea how to implement this and am grateful for any ideas, because I have already spent some time testing and researching, so far without success, so I now hope that there is one or more clever minds here in the community who can help me.

Here is my current implementation of the ChartView:

import SwiftUI
import Charts

struct ChartView: View {

    var meterInfo: String
    var meterNumber: MeterNumber
    var unit: UnitTypes

    @ObservedObject var readings: MeterReadings

    @State private var selectedYear: String = "2023" // Startjahr

    var sortedItems: [MeterReadingItem] {
        readings.items.sorted()
    }

    //    var data: [ChartItem] = [
    //        .init(month: "09", kwhperMonth: 245),
    //        .init(month: "10", kwhperMonth: 198),
    //        .init(month: "11", kwhperMonth: 267),
    //        .init(month: "12", kwhperMonth: 287),
    //        .init(month: "01", kwhperMonth: 195),
    //        .init(month: "02", kwhperMonth: 178)
    //    ]

    var data: [ChartItem] {
        return createChartData(sortedItem: sortedItems.filter({$0.meterNumber == meterNumber.rawValue}), selectedYear: selectedYear)
    }

    var period: String {
        return createPeriod(sortedItem: sortedItems.filter({$0.meterNumber == meterNumber.rawValue}))
    }

    var valueForPeriod: String {
        let value = createValueforPeriod(sortedItem: sortedItems.filter({$0.meterNumber == meterNumber.rawValue}))
        if value == "0" {
            return "keine Daten"
        } else {
            return value
        }
    }

    var body: some View {
        GeometryReader { geometry in
            VStack {
                VStack {
                    Text("Zähler \(meterNumber.rawValue)")
                        .titleStyle()
                        .foregroundColor(.primary)
                    Text(meterInfo)
                        .font(.caption)
                        .foregroundColor(.primary.opacity(0.5))
                    HStack {
                        Text("Verbrauch im Zeitraum:")
                            .font(.caption)
                        Text(valueForPeriod)
                            .subHeadlineStyle()
                        Text(unit.rawValue)
                            .subHeadlineStyle()
                    }
                    .padding(.vertical, 2)
                    HStack {
                        Text("Effektiver Verbrauch (\(unit.rawValue))")
                            .font(.caption)
                            .foregroundColor(.primary.opacity(0.5))
                            .padding(.top, 1)
                        // Auswahl des Jahres
                        Picker("Jahr", selection: $selectedYear) {
                            ForEach(uniqueYears(from: sortedItems), id: \.self) { year in
                                Text(year)
                            }
                        }
                    }
                }

                Chart {
                    ForEach(data) { value in
                        LineMark(x: .value("Monat", value.month),
                                 y: .value("Value", value.value))
                    }
                }
                .frame(maxWidth: geometry.size.width * 1)
                .padding([.vertical,.horizontal])
                Text("Zeitraum: \(period)")
                    .font(.caption)
                    .foregroundColor(.primary.opacity(0.5))
            }
        }
    }

    // Function for extracting unique years from the data
    func uniqueYears(from data: [MeterReadingItem]) -> [String] {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy"
        let years = data.map { dateFormatter.string(from: $0.date) }
        return Array(Set(years))
    }

    func createValueforPeriod(sortedItem: [MeterReadingItem]) -> String {
        var value: Int = 0
        if let valueFirst = sortedItem.first?.value {
            if let valueLast = sortedItem.last?.value {
                value = valueFirst - valueLast
            }
        }
        return "\(value)"
    }

    func createPeriod(sortedItem: [MeterReadingItem]) -> String {
        let startDate: String  = sortedItem.last?.date.formatted(date: .numeric, time: .omitted) ?? "keine Daten"
        let endDate: String = sortedItem.first?.date.formatted(date: .numeric, time: .omitted) ?? "keine Daten"
        return "\(startDate) - \(endDate)"
    }

    func createChartData(sortedItem: [MeterReadingItem], selectedYear: String) -> [ChartItem] {
        var data: [ChartItem] = []
        var valueArray: [Int] = []
        var dateArray: [Date] = []
        var highestDatesByMonth: [Int: Date] = [:]

        // Invert the array for easier processing
        var reversedArray = Array(sortedItem.reversed())

        // Secure conversion from selectedYear to Int
        if let selectedYearInt = Int(selectedYear) {
            // Filter by the selected year and ensure that only the highest value per month is taken into account
            reversedArray = reversedArray.filter { item in
                let year = Calendar.current.component(.year, from: item.date)
                return year == selectedYearInt
            }

            for item in reversedArray {
                let month = Calendar.current.component(.month, from: item.date)
                if let highestDate = highestDatesByMonth[month] {
                    if item.date > highestDate {
                        highestDatesByMonth[month] = item.date
                    }
                } else {
                    highestDatesByMonth[month] = item.date
                }
                //print("HDM: \(highestDatesByMonth)")
            }

            reversedArray = reversedArray.filter { item in
                let month = Calendar.current.component(.month, from: item.date)
                if let highestDate = highestDatesByMonth[month] {
                    return item.date == highestDate
                } else {
                    return false
                }
            }

            //print("RA: \(reversedArray)")

            if reversedArray.count > 1 {
                for items in reversedArray {
                    valueArray.append(items.value)
                    dateArray.append(items.date)
                }
                //print("dateArray 1: \(dateArray)")

                // Ensure that dateArray is not empty before removing the first element
                if dateArray.count > 1 {
                    dateArray.removeFirst()
                }

                // Check whether there is a previous year
                let previousYear = Calendar.current.date(byAdding: .year, value: -1, to: dateArray.first ?? Date())
                print("PY: \(String(describing: previousYear))")
                if let previousYearItems = sortedItem.filter({ Calendar.current.isDate($0.date, inSameDayAs: previousYear ?? Date()) }).last {
                    // Calculation of the effective values from month to month
                    let diffValues = zip([previousYearItems.value] + valueArray.dropLast(), valueArray).map({ $1 - $0 })

                    // print("dateArray 2: \(dateArray)")
                    for i in 0..<dateArray.count {
                        let dateComponents = Calendar.current.dateComponents([.month, .year], from: dateArray[i])
                        // ...
                        print(dateComponents.month ??  0)
                        let chartData = ChartItem(month: "\(dateComponents.month ?? 0)", value: diffValues[i])
                        data.append(chartData)
                    }
                } else {
                    // Calculation of effective values from month to month without previous year's value
                    let diffValues = zip(valueArray.dropLast(), valueArray.dropFirst()).map({ $1 - $0 })

                    for i in 0..<dateArray.count {
                        let dateComponents = Calendar.current.dateComponents([.month, .year], from: dateArray[i])
                        // ...
                        print(dateComponents.month ??  0)
                        let chartData = ChartItem(month: "\(dateComponents.month ?? 0)", value: diffValues[i])
                        data.append(chartData)
                    }
                }
            } else if reversedArray.count == 1 {
                for items in reversedArray {
                    valueArray.append(items.value)
                    dateArray.append(items.date)
                }
                for i in 0..<dateArray.count {
                    let dateComponents = Calendar.current.dateComponents([.month, .year], from: dateArray[i])
                    // ...

                    let chartData = ChartItem(month: "\(dateComponents.month ?? 0)", value: valueArray[i])
                    data.append(chartData)
                }
            }
        }

        return data
    }
}

struct ChartView_Previews: PreviewProvider {
    static var previews: some View {
        ChartView(meterInfo: "Preview", meterNumber: .meter180, unit: .kwh, readings: MeterReadings())
    }
}

3      

Here's an example of how it can be done. You may need to tweak it a little bit to use with your actual data, but it shouldn't take too much massaging.

Also, I used (date: Date, value: Int) for the tuple type throughout, but you don't have to do that if you think it makes the code too cluttered. I just did it to make clearer what was going on. You could make it a little easier to read with something like typealias DateValue = (date: Date, value: Int) and then use that instead of the entire tuple type, but you can also totally leave off the named elements and change .date to .0 and .value to .1 and it will still work.

Anyway...

import Foundation

//set up test data
let fmt = DateFormatter()
fmt.dateFormat = "dd.MM.yy"

let dates = [
    fmt.date(from: "30.09.22")!, fmt.date(from: "31.10.22")!, fmt.date(from: "30.11.22")!,
    fmt.date(from: "31.12.22")!, fmt.date(from: "31.01.23")!, fmt.date(from: "28.02.23")!,
    fmt.date(from: "31.03.23")!, fmt.date(from: "18.04.23")!, fmt.date(from: "30.04.23")!,
    fmt.date(from: "31.05.23")!, fmt.date(from: "30.06.23")!, fmt.date(from: "31.07.23")!,
    fmt.date(from: "31.08.23")!, fmt.date(from: "30.09.23")!, fmt.date(from: "31.10.23")!,
]

let values = [
    712, 716, 721, 726, 730, 734, 739, 742, 744, 747, 749, 750, 752, 754, 757,
]

//combine dates and values into a single array
let dateValues: [(date: Date, value: Int)] = Array(zip(dates, values))
//print(dateValues)

//only show latest date per month
//we need a reference to the Calendar to do date comparison
let cal = Calendar.current
let filteredDates: [(date: Date, value:Int)] = dateValues
    //sort descending to make it easier to process
    .reversed()
    //then reduce our array to only show the latest date in each month
    .reduce(into: []) { acc, cur in
        //acc.isEmpty will only be true on the first trip
        //after that, we check if the current month is different from the last one we added
        if acc.isEmpty ||
            !cal.isDate(cur.date, equalTo: acc.last!.date, toGranularity: .month) {
            //if either condition is true, add the current item to our output array
            acc.append(cur)
        }
    }
    //and then reverse again to get our array back in the correct order
    .reversed()
//print(filteredDates)

//combine data array with a copy of itself, without the first element
let outputDates: [(date: Date, value: Int)] =
    Array(zip(filteredDates, filteredDates.dropFirst()))
    //and then create an array of each month and the difference in value
    //from the previous month
        .map { (item1, item2) in
            (item2.date, item2.value - item1.value)
        }
//print(outputDates)

//make a dictionary keyed by years
let groupedData: [String:[(date: Date, value:Int)]] = Dictionary(grouping: outputDates) { elem in
    elem.date.formatted(.dateTime.year(.defaultDigits))
}
//print(groupedData)

//you can likely stop here and then use groupdData in your chart
//but just for testing, we'll print our data to show that we
//have the correct output

//loop through the dictionary and print each year's data
groupedData.keys.sorted().forEach { year in
    print("Year \(year):")
    //loop through each year and print each month's data
    groupedData[year]!.forEach { (month, value) in
        print("\(month.formatted(.dateTime.month(.twoDigits))) - \(value)")
    }
}

4      

I have taken a different approach. As Paul say says it start with data! Going on your example data I made a model. A property to change the "date" that you have to Date and made it conform to Comparable

struct MeterReading: Comparable, Identifiable {
    /// ID to make SwiftUI to play nicer!
    var id = UUID()

    let date: String
    let value: Int

    /// Change string date to `Date`
    var formattedDate: Date {
        let formatter = DateFormatter()
        formatter.dateFormat = "dd.MM.y"
        return formatter.date(from: date) ?? .now
    }

    /// To be able to sort `Date`s
    /// - Parameters:
    ///   - lhs: lhs `Date`
    ///   - rhs: rhs `Date`
    /// - Returns: return `true` if lhs is less then rhs
    static func < (lhs: MeterReading, rhs: MeterReading) -> Bool {
        lhs.formattedDate < rhs.formattedDate
    }
}

Then made a extension on Date to make working with dates easier.

extension Date {
    /// Compares to `Date`s month
    /// - Parameters:
    ///   - lhs: lhs `Date`
    ///   - rhs: rhs `Date`
    /// - Returns: `true` if both `Date`s are in same month
    static func ==(lhs: Date, rhs: Date) -> Bool {
        let calendar = Calendar.current
        return calendar.isDate(lhs, equalTo: rhs, toGranularity: .month)
    }

    /// Return a number of the year of given `Date`
    public var yearNumber: Double {
        let calendar = Calendar.current
        let components = calendar.dateComponents([.year], from: self)
        guard let year = components.year else {
            fatalError("Unable to get day from date")
        }
        return Double(year)
    }
}

Made a DataController to keep all the logic in and not in View with dummy data (how you get this is depends on where you get the data). Also a computed property to sort, remove same dates in month and then calculate the diference between one month and the next. Added a method so filter to be able to filter on year.

PS added a extra date in month of april to make sure that only the last month data shows and mixed them up.

@Observable
class DataController {

    /// Dummy data
    let meterReadings: [MeterReading] = [
        .init(date: "30.09.22", value: 712),
        .init(date: "30.11.22", value: 721),
        .init(date: "31.12.22", value: 726),
        .init(date: "31.01.23", value: 730),
        .init(date: "31.10.22", value: 716),
        .init(date: "08.04.23", value: 742), // <- April
        .init(date: "18.04.23", value: 742), // <- April
        .init(date: "28.02.23", value: 734),
        .init(date: "30.04.23", value: 744), // <- April
        .init(date: "31.03.23", value: 739),
        .init(date: "31.05.23", value: 747),
        .init(date: "31.07.23", value: 750),
        .init(date: "31.08.23", value: 752),
        .init(date: "30.06.23", value: 749),
        .init(date: "30.9.23", value: 754),
        .init(date: "31.10.23", value: 757)
    ]

    /// Return a new `MeterReading` array sorted and filtered and difference between month and next month.
    var displayMeterReadings: [MeterReading] {
        /// Sort the array in to `Date` order
        let sorted = meterReadings.sorted()
        /// An array to hold meterReading that have more then one entry pet month
        var sameMonth = [MeterReading]()
        /// An array to add diference value to the month.
        var difference = [MeterReading]()

        /// Loops over sorted `meterReadings` and adds if in the same month as next to `sameMonth`
        for reading in sorted.enumerated() {
            if (reading.offset + 1) < sorted.count {
                if sorted[reading.offset].formattedDate == sorted[reading.offset + 1].formattedDate {
                    sameMonth.append(reading.element)
                }
            }
        }

        /// Creates a new array without any items from `sameMonth` array
        let unique = sorted.filter { !sameMonth.contains($0) }

        /// Loops over `unique` array and calculate the difference between one month and the next
        for i in 0 ..< unique.count {
            if (i + 1) < unique.count {
                let newValue = unique[i + 1].value - unique[i].value
                let newReadings = MeterReading(date: unique[i + 1].date, value: newValue)
                difference.append(newReadings)
            }
        }

        /// return new array of items (the first "date" in array is not there)
        return difference
    }

    /// Filters array to selected year
    /// - Parameter year: `selectedYear`
    /// - Returns: A new array with the selected year data
    func yearMeterReadings(for year: Int) -> [MeterReading] {
        displayMeterReadings.filter { $0.formattedDate.yearNumber == Double(year) }
    }
}

Now you can use in chart View

struct ContentView: View {
    @State private var dataController = DataController()

    let years = [2022, 2023]

    @State private var selectedYear = 2023

    var body: some View {
        VStack {
            Picker("Select Year", selection: $selectedYear) {
                ForEach(years, id: \.self) {
                    Text(verbatim: "\($0)")
                }
            }
            .pickerStyle(.segmented)

            Chart {
                ForEach(dataController.yearMeterReadings(for: selectedYear)) { reading in
                    LineMark(
                        x: .value("Month", reading.formattedDate.formatted(.dateTime.month(.twoDigits))),
                        y: .value("Value", reading.value)
                    )

                }
            }
            .padding()
            .frame(height: 300)

            List {
                ForEach(dataController.yearMeterReadings(for: selectedYear)) { reading in
                    LabeledContent("\(reading.formattedDate.formatted(.dateTime.month(.twoDigits)))", value: "\(reading.value)")
                }
            }
        }
    }
}

4      

@roosterboy and @NigelGee, you have made my day - thank you so much for your support and the code examples. I must honestly admit that I still lack some experience with Swift/SwiftUI, and I have to continue to work diligently through Paul's 100days SwiftUI to understand all your approaches in detail, and I certainly would not have come up with any of these solutions on my own. Anyway, I just tried @NidelGee's solution in a test project and it's exactly what I had in mind, now I just have to understand it in detail and integrate it into my project, but that's exactly what will get me further. For this reason I will also try out @roosterboy's solution so that I can learn from his approach. I am really very grateful to you and appreciate your support! If I have any questions when implementing the solution approaches, I would post it here again.

3      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.