Refactor data sources and feed model
This commit is contained in:
@@ -19,12 +19,42 @@ struct CalendarDataSourceConfig: Sendable {
|
||||
}
|
||||
|
||||
final class CalendarDataSource {
|
||||
struct CandidatesResult: Sendable {
|
||||
let candidates: [Candidate]
|
||||
let error: String?
|
||||
struct Event: Sendable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let startAt: Int
|
||||
let endAt: Int
|
||||
let isAllDay: Bool
|
||||
let location: String?
|
||||
}
|
||||
|
||||
struct Data: Sendable, Equatable {
|
||||
let events: [Event]
|
||||
}
|
||||
|
||||
struct Snapshot: Sendable, Equatable {
|
||||
let data: Data
|
||||
let diagnostics: [String: String]
|
||||
}
|
||||
|
||||
enum CalendarError: Error, LocalizedError, Sendable, Equatable {
|
||||
case accessNotGranted(diagnostics: [String: String])
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .accessNotGranted:
|
||||
return "Calendar access not granted."
|
||||
}
|
||||
}
|
||||
|
||||
var diagnostics: [String: String] {
|
||||
switch self {
|
||||
case .accessNotGranted(let diagnostics):
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let store: EKEventStore
|
||||
private let config: CalendarDataSourceConfig
|
||||
|
||||
@@ -33,7 +63,7 @@ final class CalendarDataSource {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
func candidatesWithDiagnostics(now: Int) async -> CandidatesResult {
|
||||
func dataWithDiagnostics(now: Int) async throws -> Snapshot {
|
||||
var diagnostics: [String: String] = [
|
||||
"now": String(now),
|
||||
"lookahead_sec": String(config.lookaheadSec),
|
||||
@@ -50,7 +80,7 @@ final class CalendarDataSource {
|
||||
diagnostics["access_granted"] = accessGranted ? "true" : "false"
|
||||
|
||||
guard accessGranted else {
|
||||
return CandidatesResult(candidates: [], error: "Calendar access not granted.", diagnostics: diagnostics)
|
||||
throw CalendarError.accessNotGranted(diagnostics: diagnostics)
|
||||
}
|
||||
|
||||
let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil)
|
||||
@@ -64,10 +94,10 @@ final class CalendarDataSource {
|
||||
|
||||
diagnostics["events_filtered"] = String(filtered.count)
|
||||
|
||||
let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate)
|
||||
diagnostics["candidates"] = String(candidates.count)
|
||||
let outputEvents = buildEvents(from: filtered, nowDate: nowDate)
|
||||
diagnostics["events_output"] = String(outputEvents.count)
|
||||
|
||||
return CandidatesResult(candidates: candidates, error: nil, diagnostics: diagnostics)
|
||||
return Snapshot(data: Data(events: outputEvents), diagnostics: diagnostics)
|
||||
}
|
||||
|
||||
private func shouldInclude(event: EKEvent) -> Bool {
|
||||
@@ -84,8 +114,8 @@ final class CalendarDataSource {
|
||||
return true
|
||||
}
|
||||
|
||||
private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] {
|
||||
var results: [Candidate] = []
|
||||
private func buildEvents(from events: [EKEvent], nowDate: Date) -> [Event] {
|
||||
var results: [Event] = []
|
||||
results.reserveCapacity(min(config.maxCandidates, events.count))
|
||||
|
||||
for event in events {
|
||||
@@ -99,34 +129,16 @@ final class CalendarDataSource {
|
||||
continue
|
||||
}
|
||||
|
||||
let title = (isOngoing ? "Now: \(event.title ?? "Event")" : event.title ?? "Upcoming")
|
||||
.truncated(maxLength: TextConstraints.titleMax)
|
||||
|
||||
let subtitle = subtitleText(event: event, nowDate: nowDate)
|
||||
.truncated(maxLength: TextConstraints.subtitleMax)
|
||||
|
||||
let confidence: Double = isOngoing ? 0.9 : 0.7
|
||||
let ttl = ttlSec(end: end, nowDate: nowDate)
|
||||
|
||||
let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))"
|
||||
|
||||
let title = (event.title ?? "Event").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
results.append(
|
||||
Candidate(
|
||||
Event(
|
||||
id: id,
|
||||
type: .info,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
confidence: confidence,
|
||||
createdAt: now,
|
||||
ttlSec: ttl,
|
||||
metadata: [
|
||||
"source": "eventkit",
|
||||
"calendar": event.calendar.title,
|
||||
"start": String(Int(start.timeIntervalSince1970)),
|
||||
"end": String(Int(end.timeIntervalSince1970)),
|
||||
"all_day": event.isAllDay ? "true" : "false",
|
||||
"location": event.location ?? "",
|
||||
]
|
||||
title: title.isEmpty ? "Event" : title,
|
||||
startAt: Int(start.timeIntervalSince1970),
|
||||
endAt: Int(end.timeIntervalSince1970),
|
||||
isAllDay: event.isAllDay,
|
||||
location: event.location
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -134,31 +146,6 @@ final class CalendarDataSource {
|
||||
return results
|
||||
}
|
||||
|
||||
private func ttlSec(end: Date, nowDate: Date) -> Int {
|
||||
let ttl = Int(end.timeIntervalSince(nowDate))
|
||||
// Keep the candidate alive until it ends, but cap at 2h and floor at 60s.
|
||||
return min(max(ttl, 60), 2 * 60 * 60)
|
||||
}
|
||||
|
||||
private func subtitleText(event: EKEvent, nowDate: Date) -> String {
|
||||
guard let start = event.startDate, let end = event.endDate else { return "" }
|
||||
let isOngoing = start <= nowDate && end > nowDate
|
||||
|
||||
if isOngoing {
|
||||
let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0)))
|
||||
if let loc = event.location, !loc.isEmpty {
|
||||
return "\(loc) • \(remainingMin)m left"
|
||||
}
|
||||
return "\(remainingMin)m left"
|
||||
}
|
||||
|
||||
let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0)))
|
||||
if let loc = event.location, !loc.isEmpty {
|
||||
return "In \(minutes)m • \(loc)"
|
||||
}
|
||||
return "In \(minutes)m"
|
||||
}
|
||||
|
||||
private func calendarAuthorizationStatusString() -> String {
|
||||
if #available(iOS 17.0, *) {
|
||||
return String(describing: EKEventStore.authorizationStatus(for: .event))
|
||||
@@ -169,13 +156,18 @@ final class CalendarDataSource {
|
||||
private func ensureCalendarAccess() async -> Bool {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
|
||||
if #available(iOS 17.0, *) {
|
||||
if status == .fullAccess { return true }
|
||||
if status == .writeOnly { return false }
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .notDetermined:
|
||||
return await requestAccess()
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .authorized:
|
||||
return true
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -15,13 +15,18 @@ struct POIDataSourceConfig: Sendable {
|
||||
|
||||
/// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs.
|
||||
final class POIDataSource {
|
||||
struct POI: Sendable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
private let config: POIDataSourceConfig
|
||||
|
||||
init(config: POIDataSourceConfig = .init()) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
func candidates(for location: CLLocation, now: Int) async throws -> [Candidate] {
|
||||
func data(for location: CLLocation, now: Int) async throws -> [POI] {
|
||||
// Phase 1 stub: return nothing.
|
||||
// (Still async/throws so the orchestrator can treat it uniformly with real implementations later.)
|
||||
_ = config
|
||||
@@ -30,4 +35,3 @@ final class POIDataSource {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,95 +20,83 @@ struct WeatherAlertConfig: Sendable {
|
||||
init() {}
|
||||
}
|
||||
|
||||
protocol WeatherWarningProviding: Sendable {
|
||||
func warningCandidates(location: CLLocation, now: Int) -> [Candidate]
|
||||
}
|
||||
struct WeatherWarning: Codable, Equatable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let ttlSec: Int
|
||||
let confidence: Double
|
||||
|
||||
struct NoopWeatherWarningProvider: WeatherWarningProviding {
|
||||
func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { [] }
|
||||
}
|
||||
|
||||
/// Loads mock warning candidates from a local JSON file.
|
||||
///
|
||||
/// File format:
|
||||
/// [
|
||||
/// { "id":"warn:demo", "title":"...", "subtitle":"...", "ttl_sec":3600, "confidence":0.9, "meta": { "source":"mock" } }
|
||||
/// ]
|
||||
struct LocalMockWeatherWarningProvider: WeatherWarningProviding, Sendable {
|
||||
struct MockWarning: Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let ttlSec: Int?
|
||||
let confidence: Double?
|
||||
let meta: [String: String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case subtitle
|
||||
case ttlSec = "ttl_sec"
|
||||
case confidence
|
||||
case meta
|
||||
}
|
||||
}
|
||||
|
||||
let url: URL?
|
||||
|
||||
init(url: URL?) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
func warningCandidates(location: CLLocation, now: Int) -> [Candidate] {
|
||||
guard let url else { return [] }
|
||||
guard let data = try? Data(contentsOf: url) else { return [] }
|
||||
guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] }
|
||||
return items.map { item in
|
||||
Candidate(
|
||||
id: item.id,
|
||||
type: .weatherWarning,
|
||||
title: item.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0),
|
||||
createdAt: now,
|
||||
ttlSec: max(1, item.ttlSec ?? 3600),
|
||||
metadata: item.meta
|
||||
)
|
||||
}
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case subtitle
|
||||
case ttlSec = "ttl_sec"
|
||||
case confidence
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
final class WeatherDataSource {
|
||||
struct WeatherData: Sendable, Equatable {
|
||||
struct Current: Sendable, Equatable {
|
||||
let temperatureC: Int
|
||||
let feelsLikeC: Int
|
||||
let condition: WeatherKit.WeatherCondition
|
||||
}
|
||||
|
||||
enum RainSource: String, Sendable, Equatable {
|
||||
case minutely
|
||||
case hourlyApprox
|
||||
}
|
||||
|
||||
struct RainSoon: Sendable, Equatable {
|
||||
let startAt: Int
|
||||
let ttlSec: Int
|
||||
let source: RainSource
|
||||
}
|
||||
|
||||
struct WindAlert: Sendable, Equatable {
|
||||
let gustMps: Double
|
||||
let thresholdMps: Double
|
||||
let ttlSec: Int
|
||||
}
|
||||
|
||||
let current: Current?
|
||||
let rainSoon: RainSoon?
|
||||
let windAlert: WindAlert?
|
||||
let warnings: [WeatherWarning]
|
||||
}
|
||||
|
||||
private let service: WeatherService
|
||||
private let config: WeatherAlertConfig
|
||||
private let warningProvider: WeatherWarningProviding
|
||||
|
||||
init(service: WeatherService = .shared,
|
||||
config: WeatherAlertConfig = .init(),
|
||||
warningProvider: WeatherWarningProviding = LocalMockWeatherWarningProvider(
|
||||
url: Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json")
|
||||
)) {
|
||||
config: WeatherAlertConfig = .init()) {
|
||||
self.service = service
|
||||
self.config = config
|
||||
self.warningProvider = warningProvider
|
||||
}
|
||||
|
||||
/// Returns alert candidates derived from WeatherKit forecasts.
|
||||
func candidates(for location: CLLocation, now: Int) async -> [Candidate] {
|
||||
let result = await candidatesWithDiagnostics(for: location, now: now)
|
||||
return result.candidates
|
||||
}
|
||||
|
||||
struct CandidatesResult: Sendable {
|
||||
let candidates: [Candidate]
|
||||
let weatherKitError: String?
|
||||
struct Snapshot: Sendable {
|
||||
let data: WeatherData
|
||||
let diagnostics: [String: String]
|
||||
}
|
||||
|
||||
func candidatesWithDiagnostics(for location: CLLocation, now: Int) async -> CandidatesResult {
|
||||
var results: [Candidate] = []
|
||||
var errorString: String? = nil
|
||||
enum WeatherError: Error, LocalizedError, Sendable {
|
||||
case weatherKitFailed(message: String, diagnostics: [String: String])
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .weatherKitFailed(let message, _):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dataWithDiagnostics(for location: CLLocation, now: Int) async throws -> Snapshot {
|
||||
var current: WeatherData.Current? = nil
|
||||
var rainSoon: WeatherData.RainSoon? = nil
|
||||
var windAlert: WeatherData.WindAlert? = nil
|
||||
var diagnostics: [String: String] = [
|
||||
"lat": String(format: "%.5f", location.coordinate.latitude),
|
||||
"lon": String(format: "%.5f", location.coordinate.longitude),
|
||||
@@ -120,7 +108,7 @@ final class WeatherDataSource {
|
||||
|
||||
do {
|
||||
let weather = try await service.weather(for: location)
|
||||
results.append(currentConditionsCandidate(weather: weather, location: location, now: now))
|
||||
current = currentConditions(weather: weather)
|
||||
diagnostics["minute_forecast"] = (weather.minuteForecast != nil) ? "present" : "nil"
|
||||
diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count)
|
||||
if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value {
|
||||
@@ -129,122 +117,100 @@ final class WeatherDataSource {
|
||||
diagnostics["current_gust_mps"] = "nil"
|
||||
}
|
||||
|
||||
results.append(contentsOf: rainCandidates(weather: weather, location: location, now: now))
|
||||
results.append(contentsOf: windCandidates(weather: weather, location: location, now: now))
|
||||
diagnostics["candidates_weatherkit"] = String(results.count)
|
||||
|
||||
rainSoon = rainSoonAlert(weather: weather, now: now)
|
||||
windAlert = windAlertInfo(weather: weather)
|
||||
diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new }
|
||||
} catch {
|
||||
errorString = String(describing: error)
|
||||
diagnostics["weatherkit_error"] = errorString
|
||||
let msg = String(describing: error)
|
||||
diagnostics["weatherkit_error"] = msg
|
||||
throw WeatherError.weatherKitFailed(message: msg, diagnostics: diagnostics)
|
||||
}
|
||||
|
||||
results.append(contentsOf: warningProvider.warningCandidates(location: location, now: now))
|
||||
diagnostics["candidates_total"] = String(results.count)
|
||||
return CandidatesResult(candidates: results, weatherKitError: errorString, diagnostics: diagnostics)
|
||||
let warnings = warnings(now: now)
|
||||
diagnostics["warnings_count"] = String(warnings.count)
|
||||
|
||||
let data = WeatherData(
|
||||
current: current,
|
||||
rainSoon: rainSoon,
|
||||
windAlert: windAlert,
|
||||
warnings: warnings
|
||||
)
|
||||
let output = Snapshot(data: data, diagnostics: diagnostics)
|
||||
return output
|
||||
}
|
||||
|
||||
private func currentConditionsCandidate(weather: Weather, location: CLLocation, now: Int) -> Candidate {
|
||||
private func warnings(now: Int) -> [WeatherWarning] {
|
||||
struct MockWarning: Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let ttlSec: Int?
|
||||
let confidence: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case subtitle
|
||||
case ttlSec = "ttl_sec"
|
||||
case confidence
|
||||
}
|
||||
}
|
||||
|
||||
_ = now
|
||||
guard let url = Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json") else { return [] }
|
||||
guard let data = try? Data(contentsOf: url) else { return [] }
|
||||
guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] }
|
||||
return items.map { item in
|
||||
WeatherWarning(
|
||||
id: item.id,
|
||||
title: item.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
ttlSec: max(1, item.ttlSec ?? 3600),
|
||||
confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func currentConditions(weather: Weather) -> WeatherData.Current {
|
||||
let tempC = weather.currentWeather.temperature.converted(to: .celsius).value
|
||||
let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value
|
||||
let tempInt = Int(tempC.rounded())
|
||||
let feelsInt = Int(feelsC.rounded())
|
||||
let cond = weather.currentWeather.condition.description
|
||||
let conditionEnum = weather.currentWeather.condition
|
||||
|
||||
let title = "Now \(tempInt)°C \(cond)".truncated(maxLength: TextConstraints.titleMax)
|
||||
let subtitle = "Feels \(feelsInt)°C".truncated(maxLength: TextConstraints.subtitleMax)
|
||||
|
||||
return Candidate(
|
||||
id: "wx:now:\(now / 60)",
|
||||
type: .currentWeather,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
confidence: 0.8,
|
||||
createdAt: now,
|
||||
ttlSec: 1800,
|
||||
condition: conditionEnum,
|
||||
metadata: [
|
||||
"source": "weatherkit_current",
|
||||
"lat": String(format: "%.5f", location.coordinate.latitude),
|
||||
"lon": String(format: "%.5f", location.coordinate.longitude),
|
||||
]
|
||||
)
|
||||
return WeatherData.Current(temperatureC: tempInt, feelsLikeC: feelsInt, condition: conditionEnum)
|
||||
}
|
||||
|
||||
private func rainCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] {
|
||||
private func rainSoonAlert(weather: Weather, now: Int) -> WeatherData.RainSoon? {
|
||||
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
|
||||
let lookahead = TimeInterval(config.rainLookaheadSec)
|
||||
|
||||
if let minuteForecast = weather.minuteForecast {
|
||||
if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) {
|
||||
return [
|
||||
Candidate(
|
||||
id: "wx:rain:\(Int(start.timeIntervalSince1970))",
|
||||
type: .weatherAlert,
|
||||
title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: "Carry an umbrella".truncated(maxLength: TextConstraints.subtitleMax),
|
||||
confidence: 0.9,
|
||||
createdAt: now,
|
||||
ttlSec: config.rainTTL,
|
||||
metadata: [
|
||||
"source": "weatherkit_minutely",
|
||||
"lat": String(format: "%.5f", location.coordinate.latitude),
|
||||
"lon": String(format: "%.5f", location.coordinate.longitude),
|
||||
]
|
||||
)
|
||||
]
|
||||
return WeatherData.RainSoon(
|
||||
startAt: Int(start.timeIntervalSince1970),
|
||||
ttlSec: config.rainTTL,
|
||||
source: .minutely
|
||||
)
|
||||
}
|
||||
return []
|
||||
return nil
|
||||
}
|
||||
|
||||
if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) {
|
||||
return [
|
||||
Candidate(
|
||||
id: "wx:rain:\(Int(start.timeIntervalSince1970))",
|
||||
type: .weatherAlert,
|
||||
title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: "Rain likely soon".truncated(maxLength: TextConstraints.subtitleMax),
|
||||
confidence: 0.6,
|
||||
createdAt: now,
|
||||
ttlSec: config.rainTTL,
|
||||
metadata: [
|
||||
"source": "weatherkit_hourly_approx",
|
||||
"lat": String(format: "%.5f", location.coordinate.latitude),
|
||||
"lon": String(format: "%.5f", location.coordinate.longitude),
|
||||
]
|
||||
)
|
||||
]
|
||||
return WeatherData.RainSoon(
|
||||
startAt: Int(start.timeIntervalSince1970),
|
||||
ttlSec: config.rainTTL,
|
||||
source: .hourlyApprox
|
||||
)
|
||||
}
|
||||
|
||||
return []
|
||||
return nil
|
||||
}
|
||||
|
||||
private func windCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] {
|
||||
guard let gustThreshold = config.gustThresholdMps else { return [] }
|
||||
guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return [] }
|
||||
guard gust >= gustThreshold else { return [] }
|
||||
|
||||
let mph = Int((gust * 2.236936).rounded())
|
||||
let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax)
|
||||
return [
|
||||
Candidate(
|
||||
id: "wx:wind:\(now):\(Int(gustThreshold * 10))",
|
||||
type: .weatherAlert,
|
||||
title: title,
|
||||
subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax),
|
||||
confidence: 0.8,
|
||||
createdAt: now,
|
||||
ttlSec: config.windTTL,
|
||||
metadata: [
|
||||
"source": "weatherkit_current",
|
||||
"gust_mps": String(format: "%.2f", gust),
|
||||
"threshold_mps": String(format: "%.2f", gustThreshold),
|
||||
"lat": String(format: "%.5f", location.coordinate.latitude),
|
||||
"lon": String(format: "%.5f", location.coordinate.longitude),
|
||||
]
|
||||
)
|
||||
]
|
||||
private func windAlertInfo(weather: Weather) -> WeatherData.WindAlert? {
|
||||
guard let gustThreshold = config.gustThresholdMps else { return nil }
|
||||
guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return nil }
|
||||
guard gust >= gustThreshold else { return nil }
|
||||
return WeatherData.WindAlert(gustMps: gust, thresholdMps: gustThreshold, ttlSec: config.windTTL)
|
||||
}
|
||||
|
||||
private func firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? {
|
||||
|
||||
Reference in New Issue
Block a user