0

I am using this Array extension to slice [HKQuantitySample] e.g.

let test = samplesWithoutDups.sliced(by: [.year, .month, .day], for: \.startDate)

which works well. But now I am needing to group the samples into a different period of time: 6pm the day before to 11:59pm. How can I do this?

extension Array {
  func sliced(by dateComponents: Set<Calendar.Component>, for key: KeyPath<Element, Date>) -> [Date: [Element]] {
    let initial: [Date: [Element]] = [:]
    let groupedByDateComponents = reduce(into: initial) { acc, cur in
      let components = Calendar.current.dateComponents(dateComponents, from: cur[keyPath: key])
      let date = Calendar.current.date(from: components)!
      let existing = acc[date] ?? []
      acc[date] = existing + [cur]
    }

    return groupedByDateComponents
  }
}
GarySabo
  • 5,806
  • 5
  • 49
  • 124
  • So let me make sure I understand. The function `sliced(by:for:)` takes an array of some object (probably a struct) that has at least one Date field in it. It returns a dictionary who's keys are Dates, containing all the entries in the array that have date match the set of `Calendar.Component`s that you pass into it? – Duncan C Jan 23 '21 at 00:36
  • @DuncanC yes exactly. I am working with HKQuantitySamples https://developer.apple.com/documentation/healthkit/hkquantitysample and I would like the result to be a dictionary who's keys are Dates. The Date keys can be the start or end of a given date, but the `HKQuantitySamples` should be organized like this: `sample.filter { $0.startDate >= 6pmYesterday && $0.startDate <= midnightTonight }` – GarySabo Jan 23 '21 at 00:41
  • And instead of slicing the entries in the array by objects that have dates with the same date components (same year/month/day in your example) you want to group entires that are have dates the time range 6 PM on one day to 11:59 PM the following day? Won't a date between 6 PM and 11:59 PM fall into 2 different date ranges then? (6:01 PM today falls in the "From 6 PM yesterday to 11:59 PM tonight" date range AND the "From 6 PM today until the 11:59 PM tomorrow" date range.) – Duncan C Jan 23 '21 at 00:42
  • @DuncanC yes that's why I'm having trouble with it I'm working with sleep data and the problem is that we don't all go to bed at or after midnight, so I am needing to group 6pm yesterday to midnight tonight, and assigning them with a key of today's date. – GarySabo Jan 23 '21 at 00:49
  • I don't think your requirements make sense because Dates between 6:00PM and midnight will fall into two different date ranges. If you want non-overlapping time ranges with no gaps you need to make each time range exactly 12 hours. Alternatively, you could return all Dates that fall between 6:00PM and 12:00 PM in any given day, grouped by the calendar date that day, but you would have to discard Dates that are > midnight and less than 6:00 PM – Duncan C Jan 23 '21 at 00:51
  • ...Or you could say that Dates that fall between 6:00 PM and 12:PM are included in the dictionaries for 2 different calendar days. – Duncan C Jan 23 '21 at 00:54
  • because I'm dealing with sleep I can assume that any samples after 6pm will count towards the next day's sleep total. So even though the date of the sample is the day before, I want it to be grouped with the next day. Would a custom SleepDay Struct work better than a dictionary here? – GarySabo Jan 23 '21 at 01:07
  • It sounds like you should use 6 PM to 6PM the next day as your time slices. Having the overlap to midnight the next day that you describe doesn't work. And you want the samples to be tied to the date of the starting time? – Duncan C Jan 23 '21 at 15:47

1 Answers1

1

Ok, I thought this was an interesting puzzle, so I decided to tackle it.

My working assumption is that for sleep data, records for "tonight" would be any records with a timestamp from the earliest bedtime today until that bedtime the following day. So if the earliest bedtime is 6:00 PM, times earlier than 6:00 PM today would be counted as sleep from the previous night.

Below is a Mac Command Line tool main.swift file that includes a func slicedByDay(cutoffInterval: for:) -> [Date: [Element] function very much like the function in your question. Instead of taking a Calendar component to decide how to break up the Elements in the array, it takes a Time interval that is the number of seconds since midnight for the cutoff time (The bedtime.)

It outputs a dictionary which will contain a key/value pair for every calendar day that has sleep records that fall in the "sleep time" for that day.

The code then builds an array of test structures containing dates and strings over a 20 day period. It creates from 5 to 20 sleep readings for each date in the 20 day period, each of which could be at any random time that calendar day.

The test code uses Array.slicedByDay(cutoffInterval: for:) -> [Date: [Element] to slice the array into a dictionary. It then maps the dictionary back into an array of another struct type, SlicedDatesStruct. That is a struct that contains a Date for a slice of sleep readings, and then an array of those sleep readings. It sorts the array of SlicedDatesStructs by sleep night, and for each SlicedDatesStruct entry, it also sorts the array of DateStruct objects by Date.

Finally, it logs the array of SlicedDatesStructs in a way that is fairly easy to read.

//
//  main.swift
//  DatesGroupedByNight
//
//  Created by Duncan Champney on 1/23/21.
//  Copyright 2021 Duncan Champney
//  Licensed under MIT license
/*

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/


import Foundation

extension Array {
    /**
     func slicedByDay(cutoffInterval: for:) -> [Date: [Element]

     Slices an Array of objects of type `Element` that include a Date object into a dictinary of type [Date: [Element]

     - Parameter cutoffInterval: A cutoff time, in seconds since midnight. Elements with Dates up to that cutoff will be grouped in that date's calendar day. Elements with Dates after the cuttoff wil be grouped in the following calendar day.

     if you pass in a cutoffInterval for 6PM, slicedByDay() will group the Elements into those whos date KeyPath value fall between 6 PM the prior day and 6 PM on the current day

     - Parameter key: A KeyPath of the Element to use for slicing the Elements. It  must be a Date.

     - returns: A Dictonary of type [Date: [Element]. All Elements in the Elements array for a given Date key will fall between the cutoff interval for the day prior to the Date key and the cutoff interval for the current Date key
    */
    func slicedByDay(cutoffInterval: TimeInterval, for key: KeyPath<Element, Date>) -> [Date: [Element]] {
    let initial: [Date: [Element]] = [:]
    let groupedByDateComponents = reduce(into: initial) { acc, cur in


        let date = cur[keyPath: key] //Get the date from the current record
        let midnightDate = Calendar.current.startOfDay(for:date) //Calculate a midnight date for the current record.

        //Calculate the number of seconds since midnight for the current record
        let currentDatecutoffInterval = date.timeIntervalSinceReferenceDate - midnightDate.timeIntervalSinceReferenceDate

        //If the date of the current record is less than 6 PM, use today's date as the key. Otherwise, use tomorrow's date.
        let keyDate = currentDatecutoffInterval < cutoffInterval ? midnightDate : Calendar.current.date(byAdding: .day,
                                                                                                          value: 1,
                                                                                                          to: midnightDate,
                                                                                                          wrappingComponents: false)!
      let existing = acc[keyDate] ?? []
      acc[keyDate] = existing + [cur]
    }
        return groupedByDateComponents
  }
}

extension String {

    /**
     Create a random string of letters of a given length
     - parameter count: The number of letters to return

     - returns: A string of upper-case letters of `count` length
     */
    static func randomLetters(count: Int) -> String{
        var result = ""
        let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
        for _ in 1...count {
            let aChar = alphabet.randomElement()!
            result.append(String(aChar))
        }
        return result
    }
}

//A sample struct used in a test of the `slicedByDay(cutoffInterval: for:) -> [Date: [Element]` function
struct DateStruct: CustomStringConvertible {
    let string: String
    let date: Date

    var description: String {
        let dateString = DateFormatter.localizedString(from: self.date, dateStyle: .short, timeStyle: .short)
        return "DateStruct(date:\(dateString), string: \(self.string))"
    }
}

//A struct use to hold an array of DateStruct objects that fall between the previous day's cutoff time and the cutoff time on the current `date`
struct SlicedDatesStruct: CustomStringConvertible {
    let date: Date
    let dateStructs: [DateStruct]
    var description: String {
        let dateString = DateFormatter.localizedString(from: self.date, dateStyle: .short, timeStyle: .short)
        var result = "Date: \(dateString)\n"
        for dateStruct in dateStructs {
            result.append("    " + dateStruct.description + "\n")
        }
        return result
    }

}


//First create an array of DateStruct objects

var dateStructArray = [DateStruct]()
let midnightDate = Calendar.current.startOfDay(for:Date()) //Calculate a Date for Midnight tonight.

let twentyfourHourInterval: TimeInterval = 24 * 60 * 60

var thisDate = midnightDate

//Create entries for 20 different dates
for _ in 1...20 {

    //Create between 5 and 20 entries for each date
    for _ in 1...Int.random(in: 5...20) {

        //Calculate a time interval for a random time any time in `thisDate`
        let randomInterval = Double.random(in: 0..<twentyfourHourInterval)

        //Create a Date for the randome time in `thisDate`
        let randomDate = thisDate.addingTimeInterval(randomInterval)

        //Create a random string for this DateStruct
        let aString = "Random String " + String.randomLetters(count: 5)
        let aDateStruct = DateStruct(string: aString, date: randomDate)
        dateStructArray.append(aDateStruct)
    }
    //Advance `thisDate` to the next calendar day
    thisDate = Calendar.current.date(byAdding: .day,
                                             value: 1,
                                             to: thisDate,
                                             wrappingComponents: false)!
}


//Now let's test `Array.slicedByDay(cutoffInterval: for:)` with a cutoff interval for 6:00 PM
let sixPMInterval: TimeInterval = 18 * 60 * 60 //Calculate the number of seconds to 6PM

//Slice up our dateStructArray
let slicedDateStructs = dateStructArray.slicedByDay(cutoffInterval: sixPMInterval, for: \.date)


//slicedDateStructs is a dictionary, which is unordered. Repackage the slices into an array of `SlicedDatesStruct`s, sorted by their Date keys
var dateSructSlices = [SlicedDatesStruct]()

let sortedDates = slicedDateStructs.keys.sorted(by:<)

let dateStructSlices: [SlicedDatesStruct] = sortedDates.map { aDate in
    return SlicedDatesStruct(date: aDate,
                             dateStructs: slicedDateStructs[aDate]!.sorted{$0.date<$1.date}
    )
}

for aSlice in dateStructSlices {
    print(aSlice)
}

Some sample output from a test run:

Date: 1/23/21, 12:00 AM
    DateStruct(date:1/23/21, 12:09 AM, string: Random String QWOJX)
    DateStruct(date:1/23/21, 12:35 AM, string: Random String TRWUR)
    DateStruct(date:1/23/21, 5:39 AM, string: Random String KEHWV)
    DateStruct(date:1/23/21, 7:28 AM, string: Random String UDNRK)
    DateStruct(date:1/23/21, 8:03 AM, string: Random String UWGTN)
    DateStruct(date:1/23/21, 8:46 AM, string: Random String LHOHY)
    DateStruct(date:1/23/21, 10:26 AM, string: Random String YQFEL)
    DateStruct(date:1/23/21, 3:47 PM, string: Random String TJUDN)
    DateStruct(date:1/23/21, 5:09 PM, string: Random String PGBDT)

Date: 1/24/21, 12:00 AM
    DateStruct(date:1/23/21, 7:46 PM, string: Random String CRULK)
    DateStruct(date:1/24/21, 2:00 AM, string: Random String NFJBC)
    DateStruct(date:1/24/21, 2:13 AM, string: Random String TROKQ)
    DateStruct(date:1/24/21, 2:49 AM, string: Random String OJJFT)
    DateStruct(date:1/24/21, 5:50 AM, string: Random String BXYOG)
    DateStruct(date:1/24/21, 7:09 AM, string: Random String LJUKP)
    DateStruct(date:1/24/21, 11:00 AM, string: Random String LAEZW)
    DateStruct(date:1/24/21, 11:01 AM, string: Random String JDNYH)
    DateStruct(date:1/24/21, 11:06 AM, string: Random String MEJBR)
    DateStruct(date:1/24/21, 11:47 AM, string: Random String WCWTP)
    DateStruct(date:1/24/21, 12:08 PM, string: Random String MVHLU)
    DateStruct(date:1/24/21, 12:24 PM, string: Random String ENHID)
    DateStruct(date:1/24/21, 12:34 PM, string: Random String EKLKP)
    DateStruct(date:1/24/21, 1:15 PM, string: Random String EBUFU)
    DateStruct(date:1/24/21, 1:45 PM, string: Random String EOJEB)
    DateStruct(date:1/24/21, 5:39 PM, string: Random String OTYBZ)

Date: 1/25/21, 12:00 AM
    DateStruct(date:1/24/21, 6:17 PM, string: Random String XMTQM)
    DateStruct(date:1/24/21, 6:25 PM, string: Random String UOFTJ)
    DateStruct(date:1/24/21, 7:15 PM, string: Random String EOIOL)
    DateStruct(date:1/24/21, 10:22 PM, string: Random String SNDQP)
    DateStruct(date:1/25/21, 4:58 AM, string: Random String AGNKJ)
    DateStruct(date:1/25/21, 7:14 AM, string: Random String CRUXZ)
    DateStruct(date:1/25/21, 7:15 AM, string: Random String FXTYX)
    DateStruct(date:1/25/21, 7:43 AM, string: Random String AXBBN)
    DateStruct(date:1/25/21, 8:09 AM, string: Random String KPSDN)
    DateStruct(date:1/25/21, 12:08 PM, string: Random String PLAYD)
    DateStruct(date:1/25/21, 12:08 PM, string: Random String URSHQ)
    DateStruct(date:1/25/21, 1:32 PM, string: Random String NCJYE)
Duncan C
  • 128,072
  • 22
  • 173
  • 272
  • Is that what you were after? – Duncan C Jan 23 '21 at 18:30
  • Wow thanks Duncan! You really went above and beyond here. Yes this looks like exactly what I was looking for – GarySabo Jan 23 '21 at 19:13
  • 1
    With problems like this the first thing to do is to clearly define the problem. Then, with a clear definition of the solution you can start working on solutions. If your solutions seem to involve contradictions, you should go back and check your assumptions and your definition of the problem. In your case, you had an overlap in the ranges of dates, which meant there wasn't a clear solution. – Duncan C Jan 23 '21 at 20:27