import Foundation import Combine import BinaryCodable import SwiftUI final class TemperatureStorage: ObservableObject { static var documentDirectory: URL { try! FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) } @AppStorage("newestDate") private var newestMeasurementTime: Int = 0 /** 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] = [] private let fileNameFormatter: DateFormatter private let storageFolder: URL private let overviewFileUrl: URL private let fm: FileManager private let lastValueInterval: TimeInterval init(lastMeasurements: [TemperatureMeasurement] = [], lastValueInterval: TimeInterval = 3600) { self.recentMeasurements = lastMeasurements let documentDirectory = TemperatureStorage.documentDirectory self.storageFolder = documentDirectory.appendingPathComponent("measurements") self.overviewFileUrl = 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() } private func ensureExistenceOfFolder() { guard !fm.fileExists(atPath: storageFolder.path) else { return } do { try fm.createDirectory(at: storageFolder, 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 { storageFolder.appendingPathComponent(fileName(for: dateIndex)) } private func fileUrl(for fileName: String) -> URL { storageFolder.appendingPathComponent(fileName) } private func loadLastMeasurements() { let startDate = Date().addingTimeInterval(-lastValueInterval) let todayIndex = Date().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 = yesterdayValues + todayValues log.info("Loaded \(recentMeasurements.count) recent measurements") } private func updateLastMeasurements(_ measurements: [TemperatureMeasurement]) { let startDate = Date().addingTimeInterval(-lastValueInterval).seconds let new = recentMeasurements + measurements recentMeasurements = Array(new.drop { $0.id < startDate }) log.info("\(recentMeasurements.count) recent measurements (of \(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 = measurements.filter { $0.id > lastDate } let newValues = newerValues.splitByDate() log.info("Adding \(newValues.count) of \(measurements.count) measurements") for (dateIndex, values) in newValues { let count = saveNew(values, for: dateIndex) setDailyCount(count, for: dateIndex) //log.info("Day \(dateIndex): \(count) values") } saveDailyCounts() updateLastMeasurements(measurements) } 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 = loadMeasurements(from: fileName) + measurements 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()) 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) guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex < dateIndex }) else { dailyMeasurementCounts.append(entry) return } 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: overviewFileUrl) 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: overviewFileUrl) } 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() } func recalculateDailyCounts() { do { let files = try fm.contentsOfDirectory(atPath: storageFolder.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() log.info("Daily counts recalculated from \(files.count) files") } } catch { log.error("Failed to load daily counts: \(error)") } } } 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 TemperatureStorage { static var mock: TemperatureStorage { .init(lastMeasurements: TemperatureMeasurement.mockData) } }