import Foundation import Combine import BinaryCodable import SwiftUI final class PersistentStorage: ObservableObject { static var documentDirectory: URL { try! FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) } @AppStorage("newestDate") private var newestMeasurementTime: Int = 0 @AppStorage("deviceTime") private var lastDeviceTimeData: Data? /** The date of the latest measurement. Incoming data older than this date will be rejected to prevent duplicate measurements */ private var newestMeasurementDate: Date { get { Date(seconds: newestMeasurementTime) } set { newestMeasurementTime = newValue.seconds } } @Published var recentMeasurements: [TemperatureMeasurement] @Published var dailyMeasurementCounts: [MeasurementDailyCount] = [] /// The formatter for the temperature measurement file names private let fileNameFormatter: DateFormatter /// The storage of daily temperature measurements private let temperatureStorageFolderUrl: URL /// The storage of the measurement counts per day private let dailyCountsFileUrl: URL private let fm: FileManager /// The interval in which the measurements should be kept in `recentMeasurements` private let lastValueInterval: TimeInterval init(lastMeasurements: [TemperatureMeasurement] = [], lastValueInterval: TimeInterval = 3600) { self.recentMeasurements = lastMeasurements let documentDirectory = PersistentStorage.documentDirectory self.temperatureStorageFolderUrl = documentDirectory.appendingPathComponent("measurements") self.dailyCountsFileUrl = documentDirectory.appendingPathComponent("overview.bin") self.fm = .default self.fileNameFormatter = DateFormatter() self.fileNameFormatter.dateFormat = "yyyyMMdd.bin" self.lastValueInterval = lastValueInterval if lastMeasurements.isEmpty { loadLastMeasurements() loadDailyCounts() } else { setDailyCounts(from: lastMeasurements) } ensureExistenceOfFolder() //recalculateDailyCounts() } private func ensureExistenceOfFolder() { guard !fm.fileExists(atPath: temperatureStorageFolderUrl.path) else { return } do { try fm.createDirectory(at: temperatureStorageFolderUrl, withIntermediateDirectories: true) } catch { log.error("Failed to create folder: \(error)") } } private func fileName(for date: Date) -> String { fileNameFormatter.string(from: date) } private func fileName(for index: Int) -> String { String(format: "%08d.bin", index) } private func fileUrl(for dateIndex: Int) -> URL { temperatureStorageFolderUrl.appendingPathComponent(fileName(for: dateIndex)) } private func fileUrl(for fileName: String) -> URL { temperatureStorageFolderUrl.appendingPathComponent(fileName) } private func loadLastMeasurements() { let now = Date.now let startDate = now.addingTimeInterval(-lastValueInterval) let todayIndex = now.dateIndex let todayValues = loadMeasurements(for: todayIndex) .filter { $0.date >= startDate } let dateIndexOfStart = startDate.dateIndex guard todayIndex != dateIndexOfStart else { recentMeasurements = todayValues log.info("Loaded \(recentMeasurements.count) recent measurements") return } let yesterdayValues = loadMeasurements(for: dateIndexOfStart) .filter { $0.date >= startDate } recentMeasurements = todayValues + yesterdayValues log.info("Loaded \(recentMeasurements.count) recent measurements") } private func updateLastMeasurements(_ measurements: [TemperatureMeasurement]) { let startDate = Date().addingTimeInterval(-lastValueInterval).seconds recentMeasurements = (measurements + recentMeasurements) .filter { $0.id > startDate } log.info("\(recentMeasurements.count) recent measurements (with \(measurements.count) new entries)") } private func loadMeasurements(for date: Date) -> [TemperatureMeasurement] { loadMeasurements(from: fileName(for: date)) } func loadMeasurements(for dateIndex: Int) -> [TemperatureMeasurement] { loadMeasurements(from: fileName(for: dateIndex)) } private func loadMeasurements(from fileName: String) -> [TemperatureMeasurement] { let fileUrl = fileUrl(for: fileName) guard fm.fileExists(atPath: fileUrl.path) else { log.info("No measurements for \(fileName)") return [] } do { let content = try Data(contentsOf: fileUrl) let points: [TemperatureMeasurement] = try BinaryDecoder.decode(from: content) log.info("Loaded \(points.count) points from \(fileName)") return points } catch { log.error("Failed to read file \(fileName): \(error)") return [] } } func add(_ measurements: [TemperatureMeasurement]) { let lastDate = self.newestMeasurementDate.seconds let newerValues: [TemperatureMeasurement] = measurements.filter { $0.id > lastDate }.reversed() let newValues = newerValues.splitByDate() log.info("Adding \(newerValues.count) of \(measurements.count) measurements") for (dateIndex, values) in newValues { let count = saveNew(values, for: dateIndex) setDailyCount(count, for: dateIndex) } saveDailyCounts() updateLastMeasurements(measurements) if let newest = newerValues.max()?.id { newestMeasurementTime = newest } } func removeMeasurements(for dateIndex: Int) { let fileUrl = fileUrl(for: dateIndex) guard fm.fileExists(atPath: fileUrl.path) else { log.warning("No measurements for \(fileUrl.lastPathComponent)") return } do { try fm.removeItem(at: fileUrl) dailyMeasurementCounts = dailyMeasurementCounts.filter { $0.dateIndex != dateIndex } recentMeasurements = recentMeasurements.filter { $0.date.dateIndex != dateIndex } } catch { log.error("Failed to delete \(fileUrl.lastPathComponent): \(error)") } } /** - Returns: The number of new points */ private func saveNew(_ measurements: [TemperatureMeasurement], for dateIndex: Int) -> Int { let fileName = fileName(for: dateIndex) let values = measurements + loadMeasurements(from: fileName) save(values, for: fileName) return values.count } private func save(_ measurements: [TemperatureMeasurement], for fileName: String) { let fileUrl = fileUrl(for: fileName) do { let data = try BinaryEncoder.encode(measurements.sorted().reversed()) try data.write(to: fileUrl) } catch { log.error("Failed to save \(fileName): \(error)") } } // MARK: Daily counts private func setDailyCount(_ count: Int, for dateIndex: Int) { guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else { add(dailyCount: count, for: dateIndex) return } dailyMeasurementCounts[index].count = count } private func add(dailyCount count: Int, for dateIndex: Int) { let entry = MeasurementDailyCount(dateIndex: dateIndex, count: count) let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex > dateIndex }) ?? 0 dailyMeasurementCounts.insert(entry, at: index) } private func incrementCount(for dateIndex: Int, by increment: Int = 1) { guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else { add(dailyCount: increment, for: dateIndex) return } dailyMeasurementCounts[index].count += increment } private func loadDailyCounts() { do { let data = try Data(contentsOf: dailyCountsFileUrl) dailyMeasurementCounts = try BinaryDecoder.decode(from: data) } catch { log.error("Failed to load overview: \(error)") } } private func saveDailyCounts() { do { let data = try BinaryEncoder.encode(dailyMeasurementCounts) try data.write(to: dailyCountsFileUrl) } catch { log.error("Failed to write overview: \(error)") } } private func setDailyCounts(from measurements: [TemperatureMeasurement]) { self.dailyMeasurementCounts = measurements.reduce(into: [Int: Int]()) { counts, value in let index = value.date.dateIndex counts[index] = (counts[index] ?? 0) + 1 } .map { MeasurementDailyCount(dateIndex: $0.key, count: $0.value) } .sorted() .reversed() } func recalculateDailyCounts() { do { let files = try fm.contentsOfDirectory(atPath: temperatureStorageFolderUrl.path) let newValues: [Int: Int] = files .reduce(into: [:]) { counts, fileName in let dateString = fileName.replacingOccurrences(of: ".bin", with: "") guard let dateIndex = Int(dateString) else { log.warning("Found file with invalid name \(fileName)") return } counts[dateIndex] = loadMeasurements(from: fileName).count } DispatchQueue.main.async { self.dailyMeasurementCounts = newValues .map { .init(dateIndex: $0.key, count: $0.value) } .sorted() .reversed() log.info("Daily counts recalculated from \(files.count) files") } } catch { log.error("Failed to load daily counts: \(error)") } } // MARK: Device time var lastDeviceTime: DeviceTime? { get { guard let data = lastDeviceTimeData else { return nil } do { let result: DeviceTime = try BinaryDecoder.decode(from: data) return result } catch { log.error("Failed to decode device time: \(error)") lastDeviceTimeData = nil return nil } } set { guard let newValue else { lastDeviceTimeData = nil return } do { let data = try BinaryEncoder.encode(newValue) lastDeviceTimeData = data } catch { log.error("Failed to encode device time: \(error)") lastDeviceTimeData = nil } } } } private extension Array where Element == TemperatureMeasurement { @discardableResult mutating func insertIntoSorted(_ measurement: TemperatureMeasurement) -> Bool { guard !contains(measurement) else { return false } guard let index = self.firstIndex(where: { $0.date > measurement.date }) else { append(measurement) return true } insert(measurement, at: index) return true } func splitByDate() -> [Int : [TemperatureMeasurement]] { reduce(into: [:]) { result, value in let dateIndex = value.date.dateIndex result[dateIndex] = (result[dateIndex] ?? []) + [value] } } } extension PersistentStorage { static var mock: PersistentStorage { .init(lastMeasurements: TemperatureMeasurement.mockData) } }