# WeatherKit Patterns

Larger building blocks for the `weatherkit` skill. Its SKILL.md covers setup,
queries, alerts, availability and the attribution rules; this file puts them
together.

## Contents

- [WeatherKit SwiftUI integration](#weatherkit-swiftui-integration)
- [Attribution view](#attribution-view)
- [Charts integration](#charts-integration)
- [Historical weather statistics](#historical-weather-statistics)
- [Weather condition mapping](#weather-condition-mapping)
- [Caching strategy](#caching-strategy)
- [Location-based weather](#location-based-weather)

## WeatherKit SwiftUI integration

### A weather model

One `@Observable` type fetches the datasets a dashboard needs in a single call,
together with the attribution, and exposes loading and error state.

```swift
import CoreLocation
import Observation
import WeatherKit

@MainActor
@Observable
final class ForecastBoard {
    private(set) var now: CurrentWeather?
    private(set) var hours: Forecast<HourWeather>?
    private(set) var days: Forecast<DayWeather>?
    private(set) var alerts: [WeatherAlert] = []
    private(set) var credit: WeatherAttribution?
    private(set) var busy = false
    private(set) var failure: (any Error)?

    private let source = WeatherService.shared

    func load(for place: CLLocation) async {
        busy = true
        defer { busy = false }
        do {
            let (current, hourly, daily, warnings) = try await source.weather(
                for: place, including: .current, .hourly, .daily, .alerts)
            now = current
            hours = hourly
            days = daily
            alerts = warnings ?? []
            credit = try await source.attribution
            failure = nil
        } catch {
            failure = error
        }
    }
}
```

### Dashboard view

```swift
struct ForecastScreen: View {
    @State private var board = ForecastBoard()
    let place: CLLocation

    var body: some View {
        ScrollView {
            if board.busy && board.now == nil {
                ProgressView().padding(.top, 80)
            } else {
                VStack(alignment: .leading, spacing: 24) {
                    if let now = board.now { NowPanel(now: now) }
                    if let hours = board.hours { HourStrip(hours: hours) }
                    if let days = board.days { DayList(days: days) }
                    if !board.alerts.isEmpty { AlertList(alerts: board.alerts) }
                    if let credit = board.credit { CreditFooter(credit: credit) }
                }
                .padding()
            }
        }
        .task { await board.load(for: place) }
        .refreshable { await board.load(for: place) }
    }
}
```

The panels:

```swift
struct NowPanel: View {
    let now: CurrentWeather

    var body: some View {
        HStack(spacing: 16) {
            Image(systemName: now.symbolName)
                .symbolRenderingMode(.multicolor)
                .font(.system(size: 56))
            VStack(alignment: .leading) {
                Text(now.temperature.formatted()).font(.largeTitle.bold())
                Text(now.condition.description)
                Text("Humidity \(now.humidity.formatted(.percent))  Wind \(now.wind.speed.formatted())  UV \(now.uvIndex.value)")
                    .font(.footnote)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

struct HourStrip: View {
    let hours: Forecast<HourWeather>

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 18) {
                ForEach(hours.prefix(12), id: \.date) { slot in
                    VStack(spacing: 6) {
                        Text(slot.date, format: .dateTime.hour())
                        Image(systemName: slot.symbolName).symbolRenderingMode(.multicolor)
                        Text(slot.temperature.formatted(.measurement(width: .narrow)))
                    }
                    .font(.caption)
                }
            }
        }
        .scrollIndicators(.hidden)
    }
}

struct DayList: View {
    let days: Forecast<DayWeather>

    var body: some View {
        Grid(alignment: .leading, verticalSpacing: 10) {
            ForEach(days.forecast, id: \.date) { entry in
                GridRow {
                    Text(entry.date.formatted(.dateTime.weekday(.abbreviated)))
                    Image(systemName: entry.symbolName).symbolRenderingMode(.multicolor)
                    Text(entry.lowTemperature.formatted(.measurement(width: .narrow)))
                    Capsule()
                        .fill(LinearGradient(colors: [.teal, .orange], startPoint: .leading, endPoint: .trailing))
                        .frame(maxHeight: 5)
                        .containerRelativeFrame(.horizontal) { width, _ in width * 0.35 }
                    Text(entry.highTemperature.formatted(.measurement(width: .narrow)))
                }
            }
        }
    }
}

struct AlertList: View {
    let alerts: [WeatherAlert]

    var body: some View {
        ForEach(alerts, id: \.detailsURL) { warning in
            let isExtreme = warning.severity == .extreme
            Link(destination: warning.detailsURL) {
                Label(warning.summary, systemImage: "exclamationmark.octagon.fill")
                    .foregroundStyle(isExtreme ? Color.red : Color.orange)
            }
        }
    }
}
```

## Attribution view

A compact footer: a 12-point mark that falls back to the service name while it
loads, and a link to the data sources.

```swift
struct CreditFooter: View {
    let credit: WeatherAttribution
    @Environment(\.colorScheme) private var scheme

    var body: some View {
        HStack(spacing: 8) {
            AsyncImage(url: scheme == .dark ? credit.combinedMarkDarkURL : credit.combinedMarkLightURL) { image in
                image.resizable().scaledToFit()
            } placeholder: {
                Text(credit.serviceName)
            }
            .frame(height: 12)

            Link("Data Sources", destination: credit.legalPageURL)
        }
        .font(.caption2)
        .foregroundStyle(.secondary)
    }
}
```

## Charts integration

### Hourly temperature line

```swift
import Charts

struct TemperatureTrend: View {
    let hours: Forecast<HourWeather>

    var body: some View {
        Chart(hours.prefix(24), id: \.date) { slot in
            let degrees = slot.temperature.converted(to: .celsius).value
            AreaMark(x: .value("Time", slot.date), y: .value("Temperature", degrees))
                .foregroundStyle(.orange.opacity(0.2))
                .interpolationMethod(.catmullRom)
            LineMark(x: .value("Time", slot.date), y: .value("Temperature", degrees))
                .foregroundStyle(.orange)
                .interpolationMethod(.catmullRom)
        }
        .chartXAxis {
            AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
                AxisGridLine()
                AxisValueLabel(format: .dateTime.hour())
            }
        }
        .frame(minHeight: 160)
    }
}
```

Convert to one unit before plotting; mixing units on one axis gives a
meaningless line. For display text, keep `formatted()` so the user's unit
applies.

### Daily precipitation bars

```swift
struct RainOutlook: View {
    let days: Forecast<DayWeather>

    var body: some View {
        Chart(days.forecast, id: \.date) { entry in
            let odds = entry.precipitationChance
            BarMark(x: .value("Date", entry.date, unit: .day), y: .value("Rain", odds))
                .foregroundStyle(Color.blue.gradient)
        }
        .chartYScale(domain: 0...1)
        .chartYAxis {
            AxisMarks { mark in
                AxisValueLabel {
                    if let share = mark.as(Double.self) { Text(share, format: .percent) }
                }
            }
        }
        .chartXAxis {
            AxisMarks(values: .stride(by: .day)) { _ in
                AxisValueLabel(format: .dateTime.weekday(.narrow), centered: true)
            }
        }
    }
}
```

## Historical weather statistics

iOS 18 added statistics that describe what is normal for a place, derived from
long-term records. The methods take variadic queries and return one result per
query.

```swift
@available(iOS 18.0, *)
func typicalJune(of year: Int, at place: CLLocation) async throws -> [(day: Int, low: String, high: String)] {
    let calendar = Calendar(identifier: .gregorian)
    let juneFirst = DateComponents(year: year, month: 6, day: 1)
    let julyFirst = DateComponents(year: year, month: 7, day: 1)
    guard let start = calendar.date(from: juneFirst), let end = calendar.date(from: julyFirst) else { return [] }

    let service = WeatherService.shared
    let (temperatures, precipitation) = try await service.dailyStatistics(
        for: place,
        forDaysIn: DateInterval(start: start, end: end),
        including: .temperature, .precipitation
    )
    _ = precipitation.days.map(\.averagePrecipitationProbability)

    return temperatures.days.map { stats in
        (stats.day, stats.averageLowTemperature.formatted(), stats.averageHighTemperature.formatted())
    }
}
```

- `DailyWeatherStatistics<T>` is a collection; its `days` array holds
  `DayTemperatureStatistics` (`day`, `averageLowTemperature`,
  `averageHighTemperature`) or `DayPrecipitationStatistics` (`day`,
  `averagePrecipitationProbability`, `averagePrecipitationAmount`,
  `averageSnowfallAmount`). `day` is a day number, not a `Date`.
- `monthlyStatistics(for:including:)` returns `MonthlyWeatherStatistics<T>`
  whose `months` array holds `MonthTemperatureStatistics` (`month`,
  `averageLowTemperature`, `averageHighTemperature`) or the precipitation
  equivalent.
- `hourlyStatistics(for:forHoursIn:including:)` and
  `dailySummary(for:forDaysIn:including:)` follow the same shape for hourly
  normals and for observed daily totals.

```swift
@available(iOS 18.0, *)
func monthlyNormals(at place: CLLocation) async throws -> [Int: String] {
    let service = WeatherService.shared
    let normals = try await service.monthlyStatistics(for: place, including: .temperature)
    return Dictionary(uniqueKeysWithValues: normals.months.map { ($0.month, $0.averageHighTemperature.formatted()) })
}
```

## Weather condition mapping

`WeatherCondition` has a readable `description`, but apps often want their own
icon or grouping. Switch over every case and keep `@unknown default` for cases
added later.

```swift
func skyGroup(_ condition: WeatherCondition) -> String {
    switch condition {
    case .clear, .mostlyClear, .hot:
        return "sunny"
    case .partlyCloudy, .mostlyCloudy, .cloudy:
        return "cloudy"
    case .drizzle, .rain, .heavyRain, .sunShowers:
        return "rain"
    case .snow, .heavySnow, .flurries, .sunFlurries, .blowingSnow, .blizzard:
        return "snow"
    case .sleet, .freezingRain, .freezingDrizzle, .wintryMix, .hail, .frigid:
        return "ice"
    case .thunderstorms, .isolatedThunderstorms, .scatteredThunderstorms,
         .strongStorms, .tropicalStorm, .hurricane:
        return "storm"
    case .foggy, .haze, .smoky, .blowingDust:
        return "low visibility"
    case .breezy, .windy:
        return "wind"
    @unknown default:
        return "unknown"
    }
}
```

`WeatherSeverity` orders alerts. Rank it explicitly:

```swift
func rank(_ severity: WeatherSeverity) -> Int {
    switch severity {
    case .extreme:  4
    case .severe:   3
    case .moderate: 2
    case .minor:    1
    case .unknown:  0
    @unknown default: 0
    }
}
```

## Caching strategy

An actor keeps recent results per rounded coordinate and throws them away once
they are older than the staleness window.

```swift
actor ForecastCache {
    struct Snapshot {
        let now: CurrentWeather
        let hours: Forecast<HourWeather>
        let days: Forecast<DayWeather>
        let fetchedAt: Date
    }

    private var store: [String: Snapshot] = [:]
    private let maxAge: TimeInterval

    init(maxAge: TimeInterval = 600) { self.maxAge = maxAge }

    // Two decimal places is roughly a one-kilometre grid.
    private func key(for place: CLLocation) -> String {
        let spot = place.coordinate
        return String(format: "%.2f|%.2f", spot.latitude, spot.longitude)
    }

    func snapshot(for place: CLLocation) -> Snapshot? {
        let slot = key(for: place)
        guard let hit = store[slot] else { return nil }
        if Date.now.timeIntervalSince(hit.fetchedAt) > maxAge {
            store[slot] = nil
            return nil
        }
        return hit
    }

    func keep(_ snapshot: Snapshot, for place: CLLocation) {
        store[key(for: place)] = snapshot
    }
}

func freshForecast(at place: CLLocation, cache: ForecastCache) async throws -> ForecastCache.Snapshot {
    if let cached = await cache.snapshot(for: place) { return cached }
    let service = WeatherService.shared
    let (now, hours, days) = try await service.weather(for: place, including: .current, .hourly, .daily)
    let snapshot = ForecastCache.Snapshot(now: now, hours: hours, days: days, fetchedAt: .now)
    await cache.keep(snapshot, for: place)
    return snapshot
}
```

## Location-based weather

A small location provider: ask for when-in-use permission, request one fix at
kilometre accuracy (plenty for weather), then fetch current conditions.

```swift
@MainActor
@Observable
final class LocalConditions: NSObject, CLLocationManagerDelegate {
    private(set) var now: CurrentWeather?
    private(set) var problem: String?
    private let locator = CLLocationManager()

    override init() {
        super.init()
        locator.delegate = self
        locator.desiredAccuracy = kCLLocationAccuracyKilometer
    }

    func start() {
        locator.requestWhenInUseAuthorization()
        locator.requestLocation()
    }

    nonisolated func locationManager(_ source: CLLocationManager, didUpdateLocations fixes: [CLLocation]) {
        guard let latest = fixes.last else { return }
        Task { @MainActor in await refresh(at: latest) }
    }

    private func refresh(at spot: CLLocation) async {
        do {
            let service = WeatherService.shared
            now = try await service.weather(for: spot, including: .current)
        } catch {
            problem = error.localizedDescription
        }
    }

    nonisolated func locationManager(_ source: CLLocationManager, didFailWithError error: any Error) {
        let message = error.localizedDescription
        Task { @MainActor in problem = message }
    }
}
```
