summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-05-07 01:24:28 +0200
committerivar <i@oiee.no>2026-05-07 01:24:28 +0200
commit6eb17a18e901e2d7faa219d7e5a79083a5891dc9 (patch)
tree3d0796e1e567864dfdf7c675f7e8a5a40fb51a95
parent4fb690150b77afced6453e6bdb14cc4cf00d5305 (diff)
downloadsolverv-master.tar.xz
solverv-master.zip
RefactorsHEADmaster
-rw-r--r--AGENTS.md151
-rw-r--r--Shared/Models/Season.swift9
-rw-r--r--Shared/Models/SolsticeData.swift111
-rw-r--r--Shared/Models/SolsticeEvent.swift15
-rw-r--r--Solsnu.Widget/Solsnu_Widget.swift234
-rw-r--r--Solsnu.Widget/Solsnu_WidgetBundle.swift21
-rw-r--r--Solsnu.Widget/Solsnu_WidgetControl.swift55
-rw-r--r--Solsnu.Widget/Views/MediumWidgetView.swift30
-rw-r--r--Solsnu.Widget/Views/SmallWidgetView.swift38
-rw-r--r--Solverv.xcodeproj/project.pbxproj15
-rw-r--r--Solverv.xcodeproj/xcuserdata/ivarlovlie.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist6
-rw-r--r--Solverv/ContentView.swift169
-rw-r--r--Solverv/SolvervApp.swift1
-rw-r--r--Solverv/Views/InfoScreenView.swift200
-rw-r--r--docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md1606
-rw-r--r--docs/superpowers/plans/2026-03-23-sunrise-sunset-widget-implementation.md539
-rw-r--r--docs/superpowers/specs/2026-03-23-solstice-widget-design.md307
-rw-r--r--docs/superpowers/specs/2026-03-23-sunrise-sunset-widget-design.md152
18 files changed, 520 insertions, 3139 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..da711d2
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,151 @@
+# Solverv — Agent Reference
+
+Norwegian solstice and equinox countdown app for iOS, with a WidgetKit extension.
+Built with SwiftUI, SwiftData, CoreLocation, and WidgetKit.
+
+## Repository layout
+
+```
+Solverv/ Main app target
+ SolvervApp.swift @main entry point, LocationManager
+ ContentView.swift Primary UI (struct is ContentView; file header says InfoScreenView)
+ Item.swift SwiftData @Model stub (Xcode template remnant, not used by UI)
+ Assets.xcassets/
+
+Shared/ Code compiled into both the app and widget targets
+ Models/
+ SolsticeData.swift Singleton; hardcoded events 2025–2030; query API
+ SolsticeEvent.swift Identifiable+Codable struct; daysUntil(), localDateTime()
+ Season.swift Enum: spring/summer/autumn/winter; color, assetName, fromDate()
+ Utilities/
+ SunTimes.swift NOAA solar algorithm (zenith 90.833°)
+ AppGroupManager.swift App Group bridge (location + sun times)
+
+Solsnu.Widget/ WidgetKit extension
+ Solsnu_Widget.swift Provider, SolvervEntry, Solsnu_Widget config, SolvervDef view-model
+ Views/
+ SmallWidgetView.swift
+ MediumWidgetView.swift
+
+SolverVTests/ XCTest suite
+ Models/SolsticeDataTests.swift
+ Utilities/SunTimesTests.swift, AppGroupManagerTests.swift
+
+Solverv.xcodeproj/ Xcode project; no SPM Package.swift, no Podfile
+```
+
+## Key types and contracts
+
+### `SolsticeData` (Shared/Models/)
+Singleton (`SolsticeData.shared`). Stores hardcoded UTC events from 2025 through 2030,
+sorted ascending. All query methods take an optional `now: Date` (defaults to `Date()`).
+
+- `nextEvent(from:) -> SolsticeEvent?` — first event with `date > now`
+- `upcomingEvents(count:from:) -> [SolsticeEvent]` — next N events
+- `progressToNextEvent(from:) -> (elapsed: Int, total: Int)?` — day counts between
+ the previous and next event; used for progress bars
+
+**Coverage ends at 2030.** Agents adding future events must follow the existing pattern:
+`SolsticeEvent(name: "<Norwegian name> <year>", date: SolsticeData.dateFromUTC(...), season: .<season>)`.
+
+### `SolsticeEvent` (Shared/Models/)
+`Identifiable, Codable`. UUID is generated at init (not stable across serialization rounds).
+
+- `daysUntil(from:) -> Int` — calendar-day difference, floored at 0
+- `localDateTime() -> Date` — **known bug**: adds UTC offset as raw seconds into
+ DateComponents rather than using a proper timezone-aware conversion. Do not expand
+ callers of this method without fixing it first.
+
+### `Season` (Shared/Models/)
+`fromDate(_:)` uses meteorological (month-based) seasons, not astronomical ones.
+`assetName` returns `"Season\(displayName)"` — maps to named color sets in Assets.xcassets.
+
+### `SunTimes` (Shared/Utilities/)
+Pure value type. Implements the NOAA solar position algorithm (zenith = 90.833° to
+account for atmospheric refraction + solar disk radius). Returns `nil` for polar
+night/midnight-sun conditions (cosH out of [-1, 1] range). No caching; callers
+instantiate per-date.
+
+```swift
+let st = SunTimes(latitude: lat, longitude: lon, date: date)
+let sunrise = st.sunrise() // Date? in device local time
+let sunset = st.sunset() // Date? in device local time
+```
+
+### `AppGroupManager` (Shared/Utilities/)
+Singleton (`AppGroupManager.shared`). App Group ID: `group.com.ivarlovlie.solverv`.
+Bridges data between the app and widget via `UserDefaults(suiteName:)`.
+
+Two stored payloads:
+- `"userLocation"` → `UserLocation` (lat, lon, ISO8601 timestamp, isDefaultLocation)
+- `"sunTimes"` → `SunTimes` (date string, ISO8601 sunrise/sunset strings, timestamp)
+
+The widget treats location as stale after 24 hours. Sun times are cached separately
+but the widget recomputes them from the stored location rather than reading the cached
+sun times struct (the `"sunTimes"` key is written by the app but not read by the widget).
+
+### `SolvervDef` (Solsnu.Widget/Solsnu_Widget.swift)
+View-model struct used by widget views. Wraps `SolsticeData` and `SunTimes` queries.
+`init(utcString:)` force-unwraps the date parse — acknowledged in a comment, only used
+in preview code. Do not use this initializer in production paths.
+
+### `LocationManager` (Solverv/SolvervApp.swift)
+`CLLocationManagerDelegate`, ObservableObject. Requests `whenInUse` authorization,
+calls `startUpdatingLocation()`, saves to `AppGroupManager` on first fix, then stops.
+Does not handle permission denial or restricted state beyond silent no-op.
+
+## Data flow
+
+```
+[CLLocationManager] --location--> [LocationManager]
+ |
+ AppGroupManager.saveLocation()
+ AppGroupManager.saveSunTimes()
+ |
+ UserDefaults (App Group)
+ |
+ +------------------+------------------+
+ | |
+ [ContentView] [Widget Provider]
+ reads location, recomputes reads location, recomputes
+ SunTimes locally on loadData() SunTimes in getTimeline()
+ (every 60s via Timer) (refreshes at next midnight)
+```
+
+## Build and test
+
+This is a pure Xcode project. There is no `xcodebuild` wrapper script.
+
+Run unit tests from Xcode (Product → Test) or via:
+```
+xcodebuild test \
+ -project Solverv.xcodeproj \
+ -scheme Solverv \
+ -destination 'platform=iOS Simulator,name=iPhone 16'
+```
+
+Tests import the main target with `@testable import Solverv`. The widget target is not
+separately testable via the current test scheme.
+
+## Conventions
+
+- Norwegian event names in `SolsticeData`: Vårjevndøgn, Sommersolverv, Høstjevndøgn, Vintersolverv.
+- All astronomical times in `SolsticeData` are UTC; `SunTimes` output is device local time.
+- `Season.fromDate(_:)` is calendar-month-based; it does not agree with the astronomical
+ season encoded in `SolsticeEvent.season`. These are intentionally different.
+- Widget timeline policy: `.after(nextMidnight)` — one entry per day.
+- `SolvervDef` is the single source of truth for widget view logic; views must not
+ call `SolsticeData` or `SunTimes` directly.
+
+## Known issues / landmines
+
+1. `SolsticeEvent.localDateTime()` applies UTC offset as raw seconds — produces wrong
+ dates in non-UTC zones with non-whole-hour offsets (e.g., India, Nepal, some AU zones).
+2. `AppGroupManager.SunTimes` (the cached struct) is written by the app but the widget
+ ignores it and recomputes from the cached location. The two caches can diverge.
+3. `SolvervDef.init(utcString:)` force-unwraps — preview-only; never call from production.
+4. `Item` (SwiftData model) is wired into `SolvervApp.sharedModelContainer` but unused.
+ Removing it requires a schema migration.
+5. `LocationManager` does not handle `.denied` or `.restricted` authorization — the app
+ shows `—` for sun times without explanation.
+6. `SolsticeData` coverage ends at Vintersolverv 2030. After that, all queries return nil.
diff --git a/Shared/Models/Season.swift b/Shared/Models/Season.swift
index c956fed..ed9b7df 100644
--- a/Shared/Models/Season.swift
+++ b/Shared/Models/Season.swift
@@ -6,6 +6,15 @@ enum Season: String, Codable {
case autumn
case winter
+ var eventName: String {
+ switch self {
+ case .spring: return "Vårjevndøgn"
+ case .summer: return "Sommersolverv"
+ case .autumn: return "Høstjevndøgn"
+ case .winter: return "Vintersolverv"
+ }
+ }
+
var displayName: String {
switch self {
case .spring: return "Spring"
diff --git a/Shared/Models/SolsticeData.swift b/Shared/Models/SolsticeData.swift
index 9285aad..d891a0a 100644
--- a/Shared/Models/SolsticeData.swift
+++ b/Shared/Models/SolsticeData.swift
@@ -6,45 +6,48 @@ class SolsticeData {
private let events: [SolsticeEvent]
private init() {
- var allEvents: [SolsticeEvent] = []
+ let table: [(year: Int, month: Int, day: Int, hour: Int, minute: Int, season: Season)] = [
+ // 2025
+ (2025, 3, 20, 9, 1, .spring),
+ (2025, 6, 20, 14, 42, .summer),
+ (2025, 9, 22, 18, 20, .autumn),
+ (2025, 12, 21, 15, 3, .winter),
+ // 2026
+ (2026, 3, 20, 14, 46, .spring),
+ (2026, 6, 21, 8, 25, .summer),
+ (2026, 9, 23, 0, 6, .autumn),
+ (2026, 12, 21, 20, 50, .winter),
+ // 2027
+ (2027, 3, 20, 20, 25, .spring),
+ (2027, 6, 21, 14, 11, .summer),
+ (2027, 9, 23, 6, 2, .autumn),
+ (2027, 12, 22, 2, 43, .winter),
+ // 2028
+ (2028, 3, 20, 2, 17, .spring),
+ (2028, 6, 20, 20, 2, .summer),
+ (2028, 9, 22, 11, 45, .autumn),
+ (2028, 12, 21, 8, 20, .winter),
+ // 2029
+ (2029, 3, 20, 8, 1, .spring),
+ (2029, 6, 21, 1, 48, .summer),
+ (2029, 9, 22, 17, 37, .autumn),
+ (2029, 12, 21, 14, 14, .winter),
+ // 2030
+ (2030, 3, 20, 13, 51, .spring),
+ (2030, 6, 21, 7, 31, .summer),
+ (2030, 9, 22, 23, 27, .autumn),
+ (2030, 12, 21, 20, 9, .winter),
+ ]
- // 2025
- allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 3, day: 20, hour: 9, minute: 1), season: .spring))
- allEvents.append(SolsticeEvent(name: "Sommersolverv 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 6, day: 20, hour: 14, minute: 42), season: .summer))
- allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 9, day: 22, hour: 18, minute: 20), season: .autumn))
- allEvents.append(SolsticeEvent(name: "Vintersolverv 2025", date: SolsticeData.dateFromUTC(year: 2025, month: 12, day: 21, hour: 15, minute: 3), season: .winter))
-
- // 2026
- allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 3, day: 20, hour: 14, minute: 46), season: .spring))
- allEvents.append(SolsticeEvent(name: "Sommersolverv 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 6, day: 21, hour: 8, minute: 25), season: .summer))
- allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 9, day: 23, hour: 0, minute: 6), season: .autumn))
- allEvents.append(SolsticeEvent(name: "Vintersolverv 2026", date: SolsticeData.dateFromUTC(year: 2026, month: 12, day: 21, hour: 20, minute: 50), season: .winter))
-
- // 2027
- allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 3, day: 20, hour: 20, minute: 25), season: .spring))
- allEvents.append(SolsticeEvent(name: "Sommersolverv 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 6, day: 21, hour: 14, minute: 11), season: .summer))
- allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 9, day: 23, hour: 6, minute: 2), season: .autumn))
- allEvents.append(SolsticeEvent(name: "Vintersolverv 2027", date: SolsticeData.dateFromUTC(year: 2027, month: 12, day: 22, hour: 2, minute: 43), season: .winter))
-
- // 2028
- allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 3, day: 20, hour: 2, minute: 17), season: .spring))
- allEvents.append(SolsticeEvent(name: "Sommersolverv 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 6, day: 20, hour: 20, minute: 2), season: .summer))
- allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 9, day: 22, hour: 11, minute: 45), season: .autumn))
- allEvents.append(SolsticeEvent(name: "Vintersolverv 2028", date: SolsticeData.dateFromUTC(year: 2028, month: 12, day: 21, hour: 8, minute: 20), season: .winter))
-
- // 2029
- allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 3, day: 20, hour: 8, minute: 1), season: .spring))
- allEvents.append(SolsticeEvent(name: "Sommersolverv 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 6, day: 21, hour: 1, minute: 48), season: .summer))
- allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 9, day: 22, hour: 17, minute: 37), season: .autumn))
- allEvents.append(SolsticeEvent(name: "Vintersolverv 2029", date: SolsticeData.dateFromUTC(year: 2029, month: 12, day: 21, hour: 14, minute: 14), season: .winter))
-
- // 2030
- allEvents.append(SolsticeEvent(name: "Vårjevndøgn 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 3, day: 20, hour: 13, minute: 51), season: .spring))
- allEvents.append(SolsticeEvent(name: "Sommersolverv 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 6, day: 21, hour: 7, minute: 31), season: .summer))
- allEvents.append(SolsticeEvent(name: "Høstjevndøgn 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 9, day: 22, hour: 23, minute: 27), season: .autumn))
- allEvents.append(SolsticeEvent(name: "Vintersolverv 2030", date: SolsticeData.dateFromUTC(year: 2030, month: 12, day: 21, hour: 20, minute: 9), season: .winter))
-
- self.events = allEvents.sorted { $0.date < $1.date }
+ events = table
+ .map { row in
+ SolsticeEvent(
+ date: SolsticeData.dateFromUTC(year: row.year, month: row.month, day: row.day,
+ hour: row.hour, minute: row.minute),
+ season: row.season
+ )
+ }
+ .sorted { $0.date < $1.date }
}
private static func dateFromUTC(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date {
@@ -60,7 +63,13 @@ class SolsticeData {
}
func nextEvent(from now: Date = Date()) -> SolsticeEvent? {
- return events.first { $0.date > now }
+ return events.first(where: { $0.date > now })
+ }
+
+ func currentEvent(from now: Date = Date()) -> SolsticeEvent? {
+ guard let nextIndex = events.firstIndex(where: { $0.date > now }),
+ nextIndex > 0 else { return nil }
+ return events[nextIndex - 1]
}
func upcomingEvents(count: Int, from now: Date = Date()) -> [SolsticeEvent] {
@@ -69,27 +78,15 @@ class SolsticeData {
}
func progressToNextEvent(from now: Date = Date()) -> (elapsed: Int, total: Int)? {
- guard let _ = nextEvent(from: now) else { return nil }
+ guard let nextIndex = events.firstIndex(where: { $0.date > now }),
+ nextIndex > 0 else { return nil }
let today = Calendar.current.startOfDay(for: now)
+ let previousEventDay = Calendar.current.startOfDay(for: events[nextIndex - 1].date)
+ let eventDay = Calendar.current.startOfDay(for: events[nextIndex].date)
- guard let previousEventIndex = events.firstIndex(where: { $0.date > now }) else {
- return nil
- }
-
- guard previousEventIndex > 0 else { return nil }
-
- let nextEvent = events[previousEventIndex]
- let previousEvent = events[previousEventIndex - 1]
-
- let previousEventDay = Calendar.current.startOfDay(for: previousEvent.date)
- let eventDay = Calendar.current.startOfDay(for: nextEvent.date)
-
- let elapsedComponents = Calendar.current.dateComponents([.day], from: previousEventDay, to: today)
- let totalComponents = Calendar.current.dateComponents([.day], from: previousEventDay, to: eventDay)
-
- let elapsed = max(0, elapsedComponents.day ?? 0)
- let total = max(1, totalComponents.day ?? 1)
+ let elapsed = max(0, Calendar.current.dateComponents([.day], from: previousEventDay, to: today).day ?? 0)
+ let total = max(1, Calendar.current.dateComponents([.day], from: previousEventDay, to: eventDay).day ?? 1)
return (elapsed: elapsed, total: total)
}
diff --git a/Shared/Models/SolsticeEvent.swift b/Shared/Models/SolsticeEvent.swift
index b28cba8..6126d2b 100644
--- a/Shared/Models/SolsticeEvent.swift
+++ b/Shared/Models/SolsticeEvent.swift
@@ -2,17 +2,26 @@ import Foundation
struct SolsticeEvent: Identifiable, Codable {
let id: UUID
- let name: String
let date: Date
let season: Season
- init(name: String, date: Date, season: Season) {
+ init(date: Date, season: Season) {
self.id = UUID()
- self.name = name
self.date = date
self.season = season
}
+ /// Full display name including year, e.g. "Vårjevndøgn 2026".
+ var name: String {
+ let year = Calendar.current.component(.year, from: date)
+ return "\(season.eventName) \(year)"
+ }
+
+ /// Event type name without year, e.g. "Vårjevndøgn".
+ var shortName: String {
+ season.eventName
+ }
+
func localDateTime() -> Date {
let utcCalendar = Calendar(identifier: .gregorian)
let utcComponents = utcCalendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
diff --git a/Solsnu.Widget/Solsnu_Widget.swift b/Solsnu.Widget/Solsnu_Widget.swift
index c36cc34..bc6fc0e 100644
--- a/Solsnu.Widget/Solsnu_Widget.swift
+++ b/Solsnu.Widget/Solsnu_Widget.swift
@@ -7,189 +7,129 @@
import WidgetKit
import SwiftUI
-import Foundation
+
+// MARK: - Provider
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SolvervEntry {
- SolvervEntry(def: SolvervDef(date: Date()))
+ .placeholder
}
- func getSnapshot(in context: Context, completion: @escaping (SolvervEntry) -> ()) {
- let entry = SolvervEntry(def: SolvervDef(date: Date()))
- completion(entry)
+ func getSnapshot(in context: Context, completion: @escaping (SolvervEntry) -> Void) {
+ completion(makeEntry(for: Date()))
}
- func getTimeline(in context: Context, completion: @escaping (Timeline<SolvervEntry>) -> ()) {
- var entries: [SolvervEntry] = []
- let currentDate = Date()
-
- // Fetch location from AppGroupManager
- var sunriseTime: Date? = nil
- var sunsetTime: Date? = nil
+ func getTimeline(in context: Context, completion: @escaping (Timeline<SolvervEntry>) -> Void) {
+ let entry = makeEntry(for: Date())
+ let nextMidnight = Calendar.current.nextDate(
+ after: Date(),
+ matching: DateComponents(hour: 0, minute: 0, second: 0),
+ matchingPolicy: .nextTime
+ ) ?? Date().addingTimeInterval(86_400)
+ completion(Timeline(entries: [entry], policy: .after(nextMidnight)))
+ }
- if let location = AppGroupManager.shared.getLocation() {
- // Check if location is fresh (< 24 hours old)
- let isoFormatter = ISO8601DateFormatter()
- if let locationTimestamp = isoFormatter.date(from: location.timestamp) {
- let hoursSinceCache = currentDate.timeIntervalSince(locationTimestamp) / 3600.0
+ private func makeEntry(for date: Date) -> SolvervEntry {
+ let progress = SolsticeData.shared.progressToNextEvent(from: date)
+ let ratio = progress.map {
+ max(0, min(1, Double($0.elapsed) / Double($0.total)))
+ } ?? 0
- if hoursSinceCache < 24.0 {
- // Location is fresh, calculate sun times
- let calculator = SunTimes(latitude: location.latitude, longitude: location.longitude, date: currentDate)
- sunriseTime = calculator.sunrise()
- sunsetTime = calculator.sunset()
- }
- }
+ let sunTimes = AppGroupManager.shared.getLocation().map {
+ SunTimes(latitude: $0.latitude, longitude: $0.longitude, date: date)
}
- let entry = SolvervEntry(def: SolvervDef(date: currentDate, sunriseTime: sunriseTime, sunsetTime: sunsetTime))
- entries.append(entry)
-
- let calendar = Calendar.current
- var components = calendar.dateComponents([.year, .month, .day], from: currentDate)
- components.hour = 0
- components.minute = 0
- components.second = 0
- let todayMidnight = calendar.date(from: components)!
- let nextMidnight = calendar.date(byAdding: .day, value: 1, to: todayMidnight)!
-
- let timeline = Timeline(entries: entries, policy: .after(nextMidnight))
- completion(timeline)
+ return SolvervEntry(
+ date: date,
+ nextEvent: SolsticeData.shared.nextEvent(from: date),
+ currentEvent: SolsticeData.shared.currentEvent(from: date),
+ progressRatio: ratio,
+ sunriseTime: sunTimes?.sunrise(),
+ sunsetTime: sunTimes?.sunset()
+ )
}
}
+// MARK: - Entry
+
struct SolvervEntry: TimelineEntry {
let date: Date
- let def: SolvervDef
-
- init(def: SolvervDef, date: Date? = nil) {
- self.date = date ?? def.date
- self.def = def
+ let nextEvent: SolsticeEvent?
+ let currentEvent: SolsticeEvent?
+ let progressRatio: Double
+ let sunriseTime: Date?
+ let sunsetTime: Date?
+
+ static let placeholder = SolvervEntry(
+ date: Date(),
+ nextEvent: nil,
+ currentEvent: nil,
+ progressRatio: 0.4,
+ sunriseTime: nil,
+ sunsetTime: nil
+ )
+}
+
+// MARK: - Family dispatch
+
+struct SolsticeWidgetView: View {
+ let entry: SolvervEntry
+ @Environment(\.widgetFamily) var widgetFamily
+
+ var body: some View {
+ switch widgetFamily {
+ case .systemMedium:
+ MediumWidgetView(entry: entry)
+ default:
+ SmallWidgetView(entry: entry)
+ }
}
}
+// MARK: - Widget
+
struct Solsnu_Widget: Widget {
- let kind: String = "Solsnu_Widget_Small"
+ let kind = "Solsnu_Widget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
- SmallWidgetView(entry: entry)
+ SolsticeWidgetView(entry: entry)
}
.contentMarginsDisabled()
.configurationDisplayName("Solstice Countdown")
.description("Days until next solstice or equinox")
- .supportedFamilies([.systemSmall,.systemMedium])
+ .supportedFamilies([.systemSmall, .systemMedium])
}
}
+// MARK: - Previews
+
#Preview(as: .systemSmall) {
Solsnu_Widget()
} timeline: {
- // Test with valid sunrise/sunset times (spring equinox, Oslo approx)
- let springDate = Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 20))!
- let sunriseTime = Calendar.current.date(bySettingHour: 7, minute: 30, second: 0, of: springDate)!
- let sunsetTime = Calendar.current.date(bySettingHour: 19, minute: 45, second: 0, of: springDate)!
-
- SolvervEntry(def: SolvervDef(date: springDate, sunriseTime: sunriseTime, sunsetTime: sunsetTime))
-
- // Test with nil times (no location)
- SolvervEntry(def: SolvervDef(date: springDate, sunriseTime: nil, sunsetTime: nil))
-
- // Original equinox dates
- SolvervEntry(def: SolvervDef(utcString: "2026-03-20 14:46:00"))
- SolvervEntry(def: SolvervDef(utcString: "2026-06-21 08:25:00"))
- SolvervEntry(def: SolvervDef(utcString: "2026-09-23 00:06:00"))
- SolvervEntry(def: SolvervDef(utcString: "2026-12-21 20:50:00"))
+ let now = Date()
+ SolvervEntry(
+ date: now,
+ nextEvent: SolsticeData.shared.nextEvent(from: now),
+ currentEvent: SolsticeData.shared.currentEvent(from: now),
+ progressRatio: 0.4,
+ sunriseTime: Calendar.current.date(bySettingHour: 6, minute: 28, second: 0, of: now),
+ sunsetTime: Calendar.current.date(bySettingHour: 21, minute: 15, second: 0, of: now)
+ )
+ SolvervEntry.placeholder
}
#Preview(as: .systemMedium) {
Solsnu_Widget()
} timeline: {
- // Test medium widget with valid sunrise/sunset times
- let springDate = Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 20))!
- let sunriseTime = Calendar.current.date(bySettingHour: 7, minute: 30, second: 0, of: springDate)!
- let sunsetTime = Calendar.current.date(bySettingHour: 19, minute: 45, second: 0, of: springDate)!
-
- SolvervEntry(def: SolvervDef(date: springDate, sunriseTime: sunriseTime, sunsetTime: sunsetTime))
-
- // Test medium widget with nil times
- SolvervEntry(def: SolvervDef(date: springDate, sunriseTime: nil, sunsetTime: nil))
-
- // Original equinox dates
- SolvervEntry(def: SolvervDef(utcString: "2026-03-20 14:46:00"))
- SolvervEntry(def: SolvervDef(utcString: "2026-06-21 08:25:00"))
-}
-
-struct SolvervDef {
- let date: Date
- let bg: String
- let sunriseTime: Date?
- let sunsetTime: Date?
-
- // Cached formatter for performance
- private static let timeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.dateFormat = "HH:mm"
- formatter.timeZone = TimeZone.current
- return formatter
- }()
-
- init(date: Date, sunriseTime: Date? = nil, sunsetTime: Date? = nil) {
- self.date = date
- self.sunriseTime = sunriseTime
- self.sunsetTime = sunsetTime
- self.bg = "smallbg"
- }
-
- init(utcString: String, sunriseTime: Date? = nil, sunsetTime: Date? = nil) {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
- formatter.timeZone = TimeZone(abbreviation: "UTC")
- // Should probably guard this, but not now
- let date = formatter.date(from: utcString)!
- self.date = date
- self.sunriseTime = sunriseTime
- self.sunsetTime = sunsetTime
- self.bg = "smallbg"
- }
-
- var sunriseFormatted: String {
- guard let time = sunriseTime else { return "" }
- return Self.timeFormatter.string(from: time)
- }
-
- var sunsetFormatted: String {
- guard let time = sunsetTime else { return "" }
- return Self.timeFormatter.string(from: time)
- }
-}
-
-extension SolvervDef {
- var season: Season {
- guard let next = nextEvent else { return .winter }
- return next.season
- }
-
- var nextEvent: SolsticeEvent? {
- SolsticeData.shared.nextEvent(from: date)
- }
-
- func daysUntilNext() -> Int {
- guard let next = nextEvent else { return 0 }
- return next.daysUntil(from: date)
- }
-
- func progressRatio() -> Double {
- guard let progress = SolsticeData.shared.progressToNextEvent(from: date) else { return 0.0 }
- let ratio = Double(progress.elapsed) / Double(progress.total)
- return max(0, min(1.0, ratio))
- }
-
- func upcomingEventsPreview(count: Int) -> [SolsticeEvent] {
- SolsticeData.shared.upcomingEvents(count: count, from: date)
- }
-
- static var preview: SolvervDef {
- SolvervDef(date: Date())
- }
+ let now = Date()
+ SolvervEntry(
+ date: now,
+ nextEvent: SolsticeData.shared.nextEvent(from: now),
+ currentEvent: SolsticeData.shared.currentEvent(from: now),
+ progressRatio: 0.4,
+ sunriseTime: Calendar.current.date(bySettingHour: 6, minute: 28, second: 0, of: now),
+ sunsetTime: Calendar.current.date(bySettingHour: 21, minute: 15, second: 0, of: now)
+ )
+ SolvervEntry.placeholder
}
diff --git a/Solsnu.Widget/Solsnu_WidgetBundle.swift b/Solsnu.Widget/Solsnu_WidgetBundle.swift
index 7bd3f1a..5a41904 100644
--- a/Solsnu.Widget/Solsnu_WidgetBundle.swift
+++ b/Solsnu.Widget/Solsnu_WidgetBundle.swift
@@ -12,26 +12,5 @@ import SwiftUI
struct Solsnu_WidgetBundle: WidgetBundle {
var body: some Widget {
Solsnu_Widget()
- Solsnu_WidgetMedium()
- }
-}
-
-struct Solsnu_WidgetMedium: Widget {
- let kind: String = "Solsnu_Widget_Medium"
-
- var body: some WidgetConfiguration {
- StaticConfiguration(kind: kind, provider: Provider()) { entry in
- if #available(iOS 17.0, *) {
- MediumWidgetView(entry: entry)
- .containerBackground(.fill.tertiary, for: .widget)
- } else {
- MediumWidgetView(entry: entry)
- .padding()
- .background()
- }
- }
- .configurationDisplayName("Solstice Countdown")
- .description("Days until next solstice or equinox")
- .supportedFamilies([.systemMedium,.systemSmall])
}
}
diff --git a/Solsnu.Widget/Solsnu_WidgetControl.swift b/Solsnu.Widget/Solsnu_WidgetControl.swift
deleted file mode 100644
index 686a824..0000000
--- a/Solsnu.Widget/Solsnu_WidgetControl.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-//
-// Solsnu_WidgetControl.swift
-// Solsnu.Widget
-//
-// Created by Ivar Løvlie on 15/12/2025.
-//
-
-import AppIntents
-import SwiftUI
-import WidgetKit
-
-struct Solsnu_WidgetControl: ControlWidget {
- var body: some ControlWidgetConfiguration {
- StaticControlConfiguration(
- kind: "ivarivarivar.Solverv.Solsnu.Widget",
- provider: Provider()
- ) { value in
- ControlWidgetToggle(
- "Start Timer",
- isOn: value,
- action: StartTimerIntent()
- ) { isRunning in
- Label(isRunning ? "On" : "Off", systemImage: "timer")
- }
- }
- .displayName("Timer")
- .description("A an example control that runs a timer.")
- }
-}
-
-extension Solsnu_WidgetControl {
- struct Provider: ControlValueProvider {
- var previewValue: Bool {
- false
- }
-
- func currentValue() async throws -> Bool {
- let isRunning = true // Check if the timer is running
- return isRunning
- }
- }
-}
-
-struct StartTimerIntent: SetValueIntent {
- static let title: LocalizedStringResource = "Start a timer"
-
- @Parameter(title: "Timer is running")
- var value: Bool
-
- func perform() async throws -> some IntentResult {
- // Start / stop the timer based on `value`.
- return .result()
- }
-}
- \ No newline at end of file
diff --git a/Solsnu.Widget/Views/MediumWidgetView.swift b/Solsnu.Widget/Views/MediumWidgetView.swift
index 69bbbe2..f6fdbd9 100644
--- a/Solsnu.Widget/Views/MediumWidgetView.swift
+++ b/Solsnu.Widget/Views/MediumWidgetView.swift
@@ -7,20 +7,30 @@ struct MediumWidgetView: View {
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
- Text("Season Event")
- .font(.headline)
- .lineLimit(1)
+ if let next = entry.nextEvent {
+ Text(next.shortName)
+ .font(.headline)
+ .lineLimit(1)
- Text("\(entry.def.daysUntilNext()) days")
- .font(.system(.title3, design: .default).weight(.semibold))
+ Text("\(next.daysUntil(from: entry.date)) dager")
+ .font(.system(.title3, design: .default).weight(.semibold))
+ }
- ProgressView(value: Double(entry.def.progressRatio()))
+ ProgressView(value: entry.progressRatio)
- if !entry.def.sunriseFormatted.isEmpty && !entry.def.sunsetFormatted.isEmpty {
+ if let sunrise = entry.sunriseTime, let sunset = entry.sunsetTime {
HStack {
- Text("Sunrise: \(entry.def.sunriseFormatted)")
+ Label {
+ Text(sunrise, style: .time)
+ } icon: {
+ Image(systemName: "sunrise.fill")
+ }
Spacer()
- Text("Sunset: \(entry.def.sunsetFormatted)")
+ Label {
+ Text(sunset, style: .time)
+ } icon: {
+ Image(systemName: "sunset.fill")
+ }
}
.font(.system(size: 12, weight: .regular))
.foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
@@ -32,6 +42,6 @@ struct MediumWidgetView: View {
Spacer()
}
.padding()
+ .containerBackground(.fill.tertiary, for: .widget)
}
}
-
diff --git a/Solsnu.Widget/Views/SmallWidgetView.swift b/Solsnu.Widget/Views/SmallWidgetView.swift
index 16ca102..b7dbf32 100644
--- a/Solsnu.Widget/Views/SmallWidgetView.swift
+++ b/Solsnu.Widget/Views/SmallWidgetView.swift
@@ -1,30 +1,34 @@
import SwiftUI
import WidgetKit
-import Combine
struct SmallWidgetView: View {
let entry: SolvervEntry
@Environment(\.widgetRenderingMode) var renderingMode
- @Environment(\.locale) var locale
-
+
var body: some View {
- ZStack {
- Image(entry.def.bg)
- VStack(spacing: 8) {
- Text("\(entry.def.daysUntilNext())")
- .font(.system(size: 26, weight: .bold, design: .serif))
- .position(x: 50,y: 50)
- .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
- .italic()
+ VStack(spacing: 8) {
+ Text("\(entry.nextEvent?.daysUntil(from: entry.date) ?? 0)")
+ .font(.system(size: 26, weight: .bold, design: .serif))
+ .italic()
+ .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
- HStack(spacing: 4) {
- Text("↑ \(entry.def.sunriseFormatted)")
- Text("↓ \(entry.def.sunsetFormatted)")
+ if let sunrise = entry.sunriseTime, let sunset = entry.sunsetTime {
+ HStack(spacing: 4) {
+ Label {
+ Text(sunrise, style: .time)
+ } icon: {
+ Image(systemName: "sunrise.fill")
+ }
+ Label {
+ Text(sunset, style: .time)
+ } icon: {
+ Image(systemName: "sunset.fill")
}
- .font(.system(size: 11, weight: .regular))
- .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
+ }
+ .font(.system(size: 11, weight: .regular))
+ .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
}
}
- .containerBackground(for: .widget, alignment: .center) { Color.clear }
+ .containerBackground(for: .widget, alignment: .center) { Color.blue }
}
}
diff --git a/Solverv.xcodeproj/project.pbxproj b/Solverv.xcodeproj/project.pbxproj
index b30d03f..e31f474 100644
--- a/Solverv.xcodeproj/project.pbxproj
+++ b/Solverv.xcodeproj/project.pbxproj
@@ -54,12 +54,6 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
- 67F6206475CCCE6E27C0A963 /* Shared */ = {
- isa = PBXFileSystemSynchronizedRootGroup;
- path = Shared;
- sourceTree = "<group>";
- };
-
1B8629C12EF0C636005A1C75 /* Solverv */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Solverv;
@@ -73,6 +67,11 @@
path = Solsnu.Widget;
sourceTree = "<group>";
};
+ 67F6206475CCCE6E27C0A963 /* Shared */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Shared;
+ sourceTree = "<group>";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -150,8 +149,8 @@
1B8629E42EF0C657005A1C75 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
- 67F6206475CCCE6E27C0A963 /* Shared */,
1B8629C12EF0C636005A1C75 /* Solverv */,
+ 67F6206475CCCE6E27C0A963 /* Shared */,
);
name = Solverv;
packageProductDependencies = (
@@ -173,8 +172,8 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
- 67F6206475CCCE6E27C0A963 /* Shared */,
1B8629D92EF0C656005A1C75 /* Solsnu.Widget */,
+ 67F6206475CCCE6E27C0A963 /* Shared */,
);
name = Solsnu.WidgetExtension;
packageProductDependencies = (
diff --git a/Solverv.xcodeproj/xcuserdata/ivarlovlie.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Solverv.xcodeproj/xcuserdata/ivarlovlie.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
new file mode 100644
index 0000000..d83d5db
--- /dev/null
+++ b/Solverv.xcodeproj/xcuserdata/ivarlovlie.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Bucket
+ uuid = "6A0A74FD-DC7D-46BF-9072-1FCC188695C0"
+ type = "1"
+ version = "2.0">
+</Bucket>
diff --git a/Solverv/ContentView.swift b/Solverv/ContentView.swift
index 38c6153..dff6e23 100644
--- a/Solverv/ContentView.swift
+++ b/Solverv/ContentView.swift
@@ -1,32 +1,169 @@
//
-// ContentView.swift
+// InfoScreenView.swift
// Solverv
//
-// Created by Ivar Løvlie on 15/12/2025.
+// Created by Ivar Løvlie on 23/03/2026.
//
import SwiftUI
+import Combine
struct ContentView: View {
+ @State private var upcomingEvents: [SolsticeEvent] = []
+ @State private var nextEvent: SolsticeEvent?
+ @State private var currentEvent: SolsticeEvent?
+ @State private var progressData: (elapsed: Int, total: Int)? = nil
+ @State private var sunriseTime: Date?
+ @State private var sunsetTime: Date?
+
var body: some View {
- NavigationStack {
- VStack {
- Text("Solstice Countdown")
- .font(.title)
+ ScrollView {
+ VStack(spacing: 24) {
+ VStack(spacing: 16) {
+ if let next = nextEvent, let current = currentEvent {
+ Image(current.season.assetName)
+ .resizable()
+ .scaledToFit()
+ .frame(height: 200)
+ .clipped()
+
+ VStack(spacing: 8) {
+ Text(current.name)
+ .font(.title3)
+
+ Text("\(next.daysUntil())")
+ .font(.system(.title, design: .default).weight(.bold))
+ .foregroundColor(next.season.colorLight)
+
+ Text("dager til \(next.shortName.lowercased())")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ if let progress = progressData {
+ ProgressView(value: Double(progress.elapsed) / Double(progress.total))
+ .tint(next.season.colorLight)
+ }
+ }
+ }
+ .padding()
+ .background(Color(.systemGray6))
+ .cornerRadius(12)
+
+ VStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Solgang idag")
+ .font(.headline)
- NavigationLink(destination: InfoScreenView()) {
- Label("View Details", systemImage: "info.circle")
- .padding()
- .background(Color.blue)
- .foregroundColor(.white)
- .cornerRadius(8)
+ HStack {
+ Label("Oppgang", systemImage: "sunrise.fill")
+ Spacer()
+ if let sunrise = sunriseTime {
+ Text(sunrise, style: .time)
+ } else {
+ Text("—").foregroundColor(.secondary)
+ }
+ }
+
+ HStack {
+ Label("Nedgang", systemImage: "sunset.fill")
+ Spacer()
+ if let sunset = sunsetTime {
+ Text(sunset, style: .time)
+ } else {
+ Text("—").foregroundColor(.secondary)
+ }
+ }
+ }
}
+ .padding()
+ .background(Color(.systemGray6))
+ .cornerRadius(12)
+
+ eventsListSection
}
- .navigationTitle("Home")
+ .padding()
+ }
+ .navigationTitle("Solverv og jevndøgn")
+ .onAppear(perform: loadData)
+ .onReceive(Timer.publish(every: 60, on: .main, in: .common).autoconnect()) { _ in
+ loadData()
}
}
-}
-#Preview {
- ContentView()
+ private var eventsListSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Fremtiden")
+ .font(.headline)
+
+ if upcomingEvents.isEmpty {
+ Text("Er her")
+ .foregroundColor(.secondary)
+ .font(.caption)
+ } else {
+ VStack(spacing: 0) {
+ ForEach(Array(upcomingEvents.enumerated()), id: \.element.id) { index, event in
+ VStack(spacing: 0) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(event.name)
+ .font(.subheadline)
+ .lineLimit(1)
+
+ Text(formatEventDate(event.localDateTime()))
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 2) {
+ Text("\(event.daysUntil())d")
+ .font(.subheadline)
+
+ Circle()
+ .fill(event.season.colorLight)
+ .frame(width: 12, height: 12)
+ }
+ }
+ .padding(.vertical, 8)
+
+ if index < upcomingEvents.count - 1 {
+ Divider()
+ }
+ }
+ }
+ }
+ }
+ }
+ .padding()
+ .background(Color(.systemGray6))
+ .cornerRadius(12)
+ }
+
+ private func loadData() {
+ nextEvent = SolsticeData.shared.nextEvent()
+ currentEvent = SolsticeData.shared.currentEvent()
+ upcomingEvents = SolsticeData.shared.upcomingEvents(count: 12)
+ progressData = SolsticeData.shared.progressToNextEvent()
+
+ // Load sunrise/sunset from location if available
+ if let location = AppGroupManager.shared.getLocation() {
+ let sunTimes = SunTimes(
+ latitude: location.latitude,
+ longitude: location.longitude,
+ date: Date()
+ )
+ sunriseTime = sunTimes.sunrise()
+ sunsetTime = sunTimes.sunset()
+ }
+ }
+
+ private func formatEventDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .short
+ formatter.timeStyle = .short
+ formatter.timeZone = .current
+ return formatter.string(from: date)
+ }
}
diff --git a/Solverv/SolvervApp.swift b/Solverv/SolvervApp.swift
index 955a946..9999f2d 100644
--- a/Solverv/SolvervApp.swift
+++ b/Solverv/SolvervApp.swift
@@ -19,7 +19,6 @@ struct SolvervApp: App {
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
-
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
diff --git a/Solverv/Views/InfoScreenView.swift b/Solverv/Views/InfoScreenView.swift
deleted file mode 100644
index d876a45..0000000
--- a/Solverv/Views/InfoScreenView.swift
+++ /dev/null
@@ -1,200 +0,0 @@
-//
-// InfoScreenView.swift
-// Solverv
-//
-// Created by Ivar Løvlie on 23/03/2026.
-//
-
-import SwiftUI
-import Combine
-
-struct InfoScreenView: View {
- @State private var upcomingEvents: [SolsticeEvent] = []
- @State private var nextEvent: SolsticeEvent?
- @State private var progressData: (elapsed: Int, total: Int)? = nil
- @State private var sunriseTime: Date?
- @State private var sunsetTime: Date?
-
- var body: some View {
- ScrollView {
- VStack(spacing: 24) {
- // Top section: Image + countdown
- VStack(spacing: 16) {
- if let next = nextEvent {
- Image(next.season.assetName)
- .resizable()
- .scaledToFit()
- .frame(height: 200)
- .clipped()
-
- VStack(spacing: 8) {
- Text(next.name)
- .font(.title3)
-
- Text("\(next.daysUntil())")
- .font(.system(.title, design: .default).weight(.bold))
- .foregroundColor(next.season.colorLight)
-
- Text("days remaining")
- .font(.caption)
- .foregroundColor(.secondary)
- }
-
- if let progress = progressData {
- ProgressView(value: Double(progress.elapsed) / Double(progress.total))
- .tint(next.season.colorLight)
- }
- }
- }
- .padding()
- .background(Color(.systemGray6))
- .cornerRadius(12)
-
- // Middle section: Sun times + season info
- VStack(spacing: 16) {
- VStack(alignment: .leading, spacing: 12) {
- Text("Today's Sun Times")
- .font(.headline)
-
- HStack {
- Label("Sunrise", systemImage: "sunrise.fill")
- Spacer()
- if let sunrise = sunriseTime {
- Text(sunrise, style: .time)
- } else {
- Text("—").foregroundColor(.secondary)
- }
- }
-
- HStack {
- Label("Sunset", systemImage: "sunset.fill")
- Spacer()
- if let sunset = sunsetTime {
- Text(sunset, style: .time)
- } else {
- Text("—").foregroundColor(.secondary)
- }
- }
- }
-
- Divider()
-
- if let next = nextEvent {
- VStack(alignment: .leading, spacing: 8) {
- Text("Current Season")
- .font(.headline)
-
- HStack(spacing: 8) {
- Circle()
- .fill(next.season.colorLight)
- .frame(width: 12, height: 12)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(next.season.displayName)
- .font(.subheadline)
-
- Text(next.season.description)
- .font(.caption)
- .foregroundColor(.secondary)
- }
- }
- }
- }
- }
- .padding()
- .background(Color(.systemGray6))
- .cornerRadius(12)
-
- // Bottom section: Upcoming events list
- eventsListSection
- }
- .padding()
- }
- .navigationTitle("Solstices & Equinoxes")
- .onAppear(perform: loadData)
- .onReceive(Timer.publish(every: 60, on: .main, in: .common).autoconnect()) { _ in
- loadData()
- }
- }
-
- private var eventsListSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- Text("Upcoming Events")
- .font(.headline)
-
- if upcomingEvents.isEmpty {
- Text("No upcoming events")
- .foregroundColor(.secondary)
- .font(.caption)
- } else {
- VStack(spacing: 0) {
- ForEach(Array(upcomingEvents.enumerated()), id: \.element.id) { index, event in
- VStack(spacing: 0) {
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- Text(event.name)
- .font(.subheadline)
- .lineLimit(1)
-
- Text(formatEventDate(event.localDateTime()))
- .font(.caption)
- .foregroundColor(.secondary)
- }
-
- Spacer()
-
- VStack(alignment: .trailing, spacing: 2) {
- Text("\(event.daysUntil())d")
- .font(.subheadline)
-
- Circle()
- .fill(event.season.colorLight)
- .frame(width: 12, height: 12)
- }
- }
- .padding(.vertical, 8)
-
- if index < upcomingEvents.count - 1 {
- Divider()
- }
- }
- }
- }
- }
- }
- .padding()
- .background(Color(.systemGray6))
- .cornerRadius(12)
- }
-
- private func loadData() {
- nextEvent = SolsticeData.shared.nextEvent()
- upcomingEvents = SolsticeData.shared.upcomingEvents(count: 12)
- progressData = SolsticeData.shared.progressToNextEvent()
-
- // Load sunrise/sunset from location if available
- if let location = AppGroupManager.shared.getLocation() {
- let sunTimes = SunTimes(
- latitude: location.latitude,
- longitude: location.longitude,
- date: Date()
- )
- sunriseTime = sunTimes.sunrise()
- sunsetTime = sunTimes.sunset()
- }
- }
-
- private func formatEventDate(_ date: Date) -> String {
- let formatter = DateFormatter()
- formatter.dateStyle = .short
- formatter.timeStyle = .short
- formatter.timeZone = .current
- return formatter.string(from: date)
- }
-}
-
-#Preview {
- NavigationStack {
- InfoScreenView()
- }
-}
diff --git a/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md b/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md
deleted file mode 100644
index 78e8b36..0000000
--- a/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md
+++ /dev/null
@@ -1,1606 +0,0 @@
-# Solstice Countdown Widget Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Build an iOS app with a widget showing countdown to the next solstice/equinox with season-specific imagery, sunrise/sunset times, and upcoming event details.
-
-**Architecture:**
-- Core models (`SolsticeEvent`, `SolsticeData`) manage solstice dates and calculations
-- `SunTimes` calculates sunrise/sunset using NOAA algorithm based on cached user location
-- `AppGroupManager` handles syncing data between main app and widget via shared container
-- Widget supports three sizes (small, medium, large) with responsive layouts
-- Main app info screen displays expanded details (upcoming events, sun times, progress)
-
-**Tech Stack:** SwiftUI, WidgetKit, Combine, UserDefaults (AppGroup), iOS 16+
-
----
-
-## File Structure
-
-### New Files to Create
-
-**Models:**
-- `Solverv/Models/SolsticeEvent.swift` — Data model for a single solstice/equinox event
-- `Solverv/Models/SolsticeData.swift` — Manager for solstice dates (2025-2030) and calculations
-- `Solverv/Models/Season.swift` — Enum for seasons with color/description
-
-**Utilities:**
-- `Solverv/Utilities/SunTimes.swift` — Sunrise/sunset calculation (NOAA algorithm)
-- `Solverv/Utilities/AppGroupManager.swift` — Read/write AppGroup container
-
-**Views (App):**
-- `Solverv/Views/InfoScreenView.swift` — Main app info screen (upcoming events, sun times, progress)
-
-**Views (Widget):**
-- `Solsnu.Widget/Views/SmallWidgetView.swift` — 169×169 widget layout
-- `Solsnu.Widget/Views/MediumWidgetView.swift` — 364×169 widget layout
-- `Solsnu.Widget/Views/LargeWidgetView.swift` — 364×364 widget layout
-
-**Tests:**
-- `SolverVTests/Models/SolsticeDataTests.swift` — Test solstice calculations
-- `SolverVTests/Utilities/SunTimesTests.swift` — Test sunrise/sunset calculations
-- `SolverVTests/Utilities/AppGroupManagerTests.swift` — Test data persistence
-
-### Files to Modify
-
-- `Solverv/SolvervApp.swift` — Add location permission request on launch
-- `Solverv/ContentView.swift` — Add navigation to info screen
-- `Solsnu.Widget/Solsnu_Widget.swift` — Update provider and widget to support all sizes
-- `Solsnu.Widget/Solsnu_WidgetBundle.swift` — Register all widget kinds
-- `Solverv/Assets.xcassets` — Add season images (light & dark variants)
-
----
-
-## Implementation Tasks
-
-### Task 1: Create Season Enum
-
-**Files:**
-- Create: `Solverv/Models/Season.swift`
-- Test: `SolverVTests/Models/SeasonTests.swift`
-
-- [ ] **Step 1: Write Season enum**
-
-```swift
-// Solverv/Models/Season.swift
-import SwiftUI
-
-enum Season: String, Codable {
- case spring
- case summer
- case autumn
- case winter
-
- var displayName: String {
- switch self {
- case .spring: return "Spring"
- case .summer: return "Summer"
- case .autumn: return "Autumn"
- case .winter: return "Winter"
- }
- }
-
- var description: String {
- switch self {
- case .spring: return "Day and night are approximately equal length"
- case .summer: return "Longest day of the year"
- case .autumn: return "Day and night are approximately equal length"
- case .winter: return "Shortest day of the year"
- }
- }
-
- var colorLight: Color {
- switch self {
- case .spring: return Color(red: 0.298, green: 0.686, blue: 0.314) // #4CAF50
- case .summer: return Color(red: 1.0, green: 0.761, blue: 0.039) // #FFC107
- case .autumn: return Color(red: 1.0, green: 0.596, blue: 0.0) // #FF9800
- case .winter: return Color(red: 0.129, green: 0.588, blue: 0.953) // #2196F3
- }
- }
-
- var assetName: String {
- return "Season\(displayName)"
- }
-
- static func fromDate(_ date: Date) -> Season {
- let month = Calendar.current.component(.month, from: date)
- switch month {
- case 3, 4, 5: return .spring
- case 6, 7, 8: return .summer
- case 9, 10, 11: return .autumn
- default: return .winter
- }
- }
-}
-```
-
-- [ ] **Step 2: Verify Season enum compiles**
-
-Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build`
-Expected: Build succeeds, no errors
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add Solverv/Models/Season.swift
-git commit -m "feat: add Season enum with colors and descriptions"
-```
-
----
-
-### Task 2: Create SolsticeEvent Model
-
-**Files:**
-- Create: `Solverv/Models/SolsticeEvent.swift`
-
-- [ ] **Step 1: Write SolsticeEvent struct**
-
-```swift
-// Solverv/Models/SolsticeEvent.swift
-import Foundation
-
-struct SolsticeEvent: Identifiable, Codable {
- let id: UUID
- let name: String
- let date: Date // UTC
- let season: Season
-
- init(name: String, date: Date, season: Season) {
- self.id = UUID()
- self.name = name
- self.date = date
- self.season = season
- }
-
- /// Convert UTC date to user's local timezone
- func localDateTime() -> Date {
- let utcCalendar = Calendar(identifier: .gregorian)
- var utcComponents = utcCalendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
- let timeZone = TimeZone.current
- let offset = timeZone.secondsFromGMT(for: date)
-
- var localCalendar = Calendar.current
- localCalendar.timeZone = timeZone
- var localComponents = utcComponents
- localComponents.second = (localComponents.second ?? 0) + offset
-
- return localCalendar.date(from: localComponents) ?? date
- }
-
- /// Days until this event from today
- func daysUntil() -> Int {
- let today = Calendar.current.startOfDay(for: Date())
- let eventDay = Calendar.current.startOfDay(for: date)
- let components = Calendar.current.dateComponents([.day], from: today, to: eventDay)
- return max(0, components.day ?? 0)
- }
-}
-```
-
-- [ ] **Step 2: Verify SolsticeEvent compiles**
-
-Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build`
-Expected: Build succeeds
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add Solverv/Models/SolsticeEvent.swift
-git commit -m "feat: add SolsticeEvent model with UTC-to-local conversion"
-```
-
----
-
-### Task 3: Create SolsticeData Manager
-
-**Files:**
-- Create: `Solverv/Models/SolsticeData.swift`
-- Test: `SolverVTests/Models/SolsticeDataTests.swift`
-
-- [ ] **Step 1: Write SolsticeData with hardcoded events**
-
-```swift
-// Solverv/Models/SolsticeData.swift
-import Foundation
-
-class SolsticeData {
- static let shared = SolsticeData()
-
- private let events: [SolsticeEvent]
-
- init() {
- // Hardcoded solstice/equinox dates (UTC)
- self.events = [
- // 2025
- SolsticeEvent(name: "Spring Equinox", date: isoDate("2025-03-20 09:01:00"), season: .spring),
- SolsticeEvent(name: "Summer Solstice", date: isoDate("2025-06-20 14:42:00"), season: .summer),
- SolsticeEvent(name: "Autumn Equinox", date: isoDate("2025-09-22 18:20:00"), season: .autumn),
- SolsticeEvent(name: "Winter Solstice", date: isoDate("2025-12-21 15:03:00"), season: .winter),
-
- // 2026
- SolsticeEvent(name: "Spring Equinox", date: isoDate("2026-03-20 14:46:00"), season: .spring),
- SolsticeEvent(name: "Summer Solstice", date: isoDate("2026-06-21 08:25:00"), season: .summer),
- SolsticeEvent(name: "Autumn Equinox", date: isoDate("2026-09-23 00:06:00"), season: .autumn),
- SolsticeEvent(name: "Winter Solstice", date: isoDate("2026-12-21 20:50:00"), season: .winter),
-
- // 2027
- SolsticeEvent(name: "Spring Equinox", date: isoDate("2027-03-20 20:25:00"), season: .spring),
- SolsticeEvent(name: "Summer Solstice", date: isoDate("2027-06-21 14:11:00"), season: .summer),
- SolsticeEvent(name: "Autumn Equinox", date: isoDate("2027-09-23 06:02:00"), season: .autumn),
- SolsticeEvent(name: "Winter Solstice", date: isoDate("2027-12-22 02:43:00"), season: .winter),
-
- // 2028
- SolsticeEvent(name: "Spring Equinox", date: isoDate("2028-03-20 02:17:00"), season: .spring),
- SolsticeEvent(name: "Summer Solstice", date: isoDate("2028-06-20 20:02:00"), season: .summer),
- SolsticeEvent(name: "Autumn Equinox", date: isoDate("2028-09-22 11:45:00"), season: .autumn),
- SolsticeEvent(name: "Winter Solstice", date: isoDate("2028-12-21 08:20:00"), season: .winter),
-
- // 2029
- SolsticeEvent(name: "Spring Equinox", date: isoDate("2029-03-20 08:01:00"), season: .spring),
- SolsticeEvent(name: "Summer Solstice", date: isoDate("2029-06-21 01:48:00"), season: .summer),
- SolsticeEvent(name: "Autumn Equinox", date: isoDate("2029-09-22 17:37:00"), season: .autumn),
- SolsticeEvent(name: "Winter Solstice", date: isoDate("2029-12-21 14:14:00"), season: .winter),
-
- // 2030
- SolsticeEvent(name: "Spring Equinox", date: isoDate("2030-03-20 13:51:00"), season: .spring),
- SolsticeEvent(name: "Summer Solstice", date: isoDate("2030-06-21 07:31:00"), season: .summer),
- SolsticeEvent(name: "Autumn Equinox", date: isoDate("2030-09-22 23:27:00"), season: .autumn),
- SolsticeEvent(name: "Winter Solstice", date: isoDate("2030-12-21 20:09:00"), season: .winter),
- ]
- }
-
- /// Return next upcoming solstice/equinox
- func nextEvent() -> SolsticeEvent? {
- let now = Date()
- return events.first { $0.date > now }
- }
-
- /// Return next N upcoming events
- func upcomingEvents(count: Int) -> [SolsticeEvent] {
- let now = Date()
- return events.filter { $0.date > now }.prefix(count).map { $0 }
- }
-
- /// Progress from last event to next event as (elapsed, total) days
- func progressToNextEvent() -> (elapsed: Int, total: Int)? {
- let now = Date()
- guard let next = nextEvent() else { return nil }
-
- // Find the previous event
- let prev = events.last { $0.date <= now }
- guard let previous = prev else { return nil }
-
- let elapsed = Calendar.current.dateComponents([.day], from: previous.date, to: now).day ?? 0
- let total = Calendar.current.dateComponents([.day], from: previous.date, to: next.date).day ?? 0
-
- return (elapsed, total)
- }
-
- private func isoDate(_ string: String) -> Date {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
- formatter.timeZone = TimeZone(abbreviation: "UTC")
- return formatter.date(from: string) ?? Date()
- }
-}
-```
-
-- [ ] **Step 2: Write test for nextEvent()**
-
-```swift
-// SolverVTests/Models/SolsticeDataTests.swift
-import XCTest
-@testable import Solverv
-
-final class SolsticeDataTests: XCTestCase {
- var data: SolsticeData!
-
- override func setUp() {
- super.setUp()
- data = SolsticeData()
- }
-
- func testNextEventReturnsValidEvent() {
- let next = data.nextEvent()
- XCTAssertNotNil(next)
- XCTAssertGreaterThan(next!.date, Date())
- }
-
- func testUpcomingEventsReturnsCorrectCount() {
- let upcoming = data.upcomingEvents(count: 3)
- XCTAssertEqual(upcoming.count, 3)
- for event in upcoming {
- XCTAssertGreaterThan(event.date, Date())
- }
- }
-
- func testProgressToNextEventReturnsValidRatio() {
- guard let progress = data.progressToNextEvent() else {
- XCTFail("Progress should not be nil")
- return
- }
- XCTAssertGreaterThanOrEqual(progress.elapsed, 0)
- XCTAssertGreaterThan(progress.total, 0)
- XCTAssertLessThanOrEqual(progress.elapsed, progress.total)
- }
-}
-```
-
-- [ ] **Step 3: Run tests**
-
-Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test`
-Expected: All tests pass
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add Solverv/Models/SolsticeData.swift SolverVTests/Models/SolsticeDataTests.swift
-git commit -m "feat: add SolsticeData manager with hardcoded events 2025-2030"
-```
-
----
-
-### Task 4: Create SunTimes Sunrise/Sunset Calculator
-
-**Files:**
-- Create: `Solverv/Utilities/SunTimes.swift`
-- Test: `SolverVTests/Utilities/SunTimesTests.swift`
-
-- [ ] **Step 1: Write test cases first (TDD)**
-
-```swift
-// SolverVTests/Utilities/SunTimesTests.swift
-import XCTest
-@testable import Solverv
-
-final class SunTimesTests: XCTestCase {
- // Known reference values from NOAA calculator
- // Location: Oslo (59.9139°N, 10.7522°E)
- // Date: March 20, 2026 (Spring Equinox)
- // Expected: Sunrise ~06:42, Sunset ~18:13 (within ±2 minutes)
-
- func testSpringEquinoxOslo() {
- let dateComponents = DateComponents(year: 2026, month: 3, day: 20, hour: 12)
- let date = Calendar.current.date(from: dateComponents)!
-
- let sunTimes = SunTimes(latitude: 59.9139, longitude: 10.7522, date: date)
- guard let sunrise = sunTimes.sunrise(), let sunset = sunTimes.sunset() else {
- XCTFail("Sunrise/sunset should not be nil")
- return
- }
-
- let sr = Calendar.current.dateComponents([.hour, .minute], from: sunrise)
- let ss = Calendar.current.dateComponents([.hour, .minute], from: sunset)
-
- // Sunrise should be ~6:42
- XCTAssertEqual(sr.hour, 6)
- XCTAssertGreaterThanOrEqual(sr.minute ?? 0, 40)
- XCTAssertLessThanOrEqual(sr.minute ?? 0, 44)
-
- // Sunset should be ~18:13
- XCTAssertEqual(ss.hour, 18)
- XCTAssertGreaterThanOrEqual(ss.minute ?? 0, 11)
- XCTAssertLessThanOrEqual(ss.minute ?? 0, 15)
- }
-
- func testSunriseBeforeSunset() {
- let dateComponents = DateComponents(year: 2026, month: 6, day: 21)
- let date = Calendar.current.date(from: dateComponents)!
-
- let sunTimes = SunTimes(latitude: 59.9139, longitude: 10.7522, date: date)
- guard let sunrise = sunTimes.sunrise(), let sunset = sunTimes.sunset() else {
- XCTFail("Sunrise/sunset should not be nil")
- return
- }
-
- XCTAssertLessThan(sunrise, sunset)
- }
-
- func testPolarNight() {
- // Tromsø, Norway (69.6°N) in December
- let dateComponents = DateComponents(year: 2026, month: 12, day: 21)
- let date = Calendar.current.date(from: dateComponents)!
-
- let sunTimes = SunTimes(latitude: 69.6, longitude: 18.95, date: date)
- // Should return nil or special handling for polar night
- _ = sunTimes.sunrise()
- _ = sunTimes.sunset()
- // Just verify no crash
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test -only SunTimesTests`
-Expected: FAIL (SunTimes class not yet created)
-
-- [ ] **Step 3: Write SunTimes class (NOAA algorithm - Part A: Core calculation)**
-
-```swift
-// Solverv/Utilities/SunTimes.swift
-import Foundation
-
-class SunTimes {
- let latitude: Double
- let longitude: Double
- let date: Date
-
- init(latitude: Double, longitude: Double, date: Date) {
- self.latitude = latitude
- self.longitude = longitude
- self.date = date
- }
-
- /// Calculate sunrise time for the location and date
- func sunrise() -> Date? {
- guard let result = calculateSunTimes() else { return nil }
- return result.sunrise
- }
-
- /// Calculate sunset time for the location and date
- func sunset() -> Date? {
- guard let result = calculateSunTimes() else { return nil }
- return result.sunset
- }
-
- // NOAA solar position algorithm (simplified)
- // Reference: https://www.esrl.noaa.gov/gmd/grad/solcalc/
- private func calculateSunTimes() -> (sunrise: Date, sunset: Date)? {
- let calendar = Calendar(identifier: .gregorian)
- let components = calendar.dateComponents([.year, .month, .day], from: date)
- guard let year = components.year, let month = components.month, let day = components.day else {
- return nil
- }
-
- // Step 1: Calculate day of year
- let dayOfYear = calendar.dateComponents([.day], from: calendar.date(from: DateComponents(year: year, month: 1, day: 1))!, to: date).day! + 1
-
- // Step 2: Fractional year in radians
- let daysInYear = isLeapYear(year) ? 366 : 365
- let gamma = 2.0 * Double.pi * Double(dayOfYear - 1) / Double(daysInYear)
-
- // Step 3: Solar declination (radians)
- let decl = 0.006918 - 0.399912 * cos(gamma) + 0.070257 * sin(gamma)
- - 0.006758 * cos(2.0 * gamma) + 0.000907 * sin(2.0 * gamma)
- - 0.002697 * cos(3.0 * gamma) + 0.00148 * sin(3.0 * gamma)
-
- // Step 4: Equation of time (minutes)
- let eot = 229.18 * (0.000075 + 0.001868 * cos(gamma) - 0.032077 * sin(gamma)
- - 0.014615 * cos(2.0 * gamma) - 0.040849 * sin(2.0 * gamma))
-
- // Step 5: Hour angle at sunrise/sunset
- let latRad = latitude * Double.pi / 180.0
- let cosH = -tan(latRad) * tan(decl)
-
- guard cosH >= -1.0 && cosH <= 1.0 else {
- // Sun is always up or always down at this latitude/date
- return nil
- }
-
- let h = acos(cosH) * 180.0 / Double.pi
-
- // Step 6: Solar noon in local solar time
- let solarNoon = 12.0 - (longitude / 15.0) - (eot / 60.0)
-
- // Step 7: Sunrise and sunset in local solar time
- let sunriseLST = solarNoon - (h / 15.0)
- let sunsetLST = solarNoon + (h / 15.0)
-
- // Step 8: Convert to standard time (account for timezone offset only, not longitude)
- let tzOffset = Double(TimeZone.current.secondsFromGMT(for: date)) / 3600.0
- let sunriseHour = sunriseLST + tzOffset
- let sunsetHour = sunsetLST + tzOffset
-
- // Step 9: Create date components, handling day boundary crossing
- let calendar2 = Calendar.current
- var baseComponents = calendar2.dateComponents([.year, .month, .day], from: date)
-
- // Sunrise
- var sunriseComp = baseComponents
- var srHour = Int(sunriseHour)
- var srMinute = Int((sunriseHour - Double(Int(sunriseHour))) * 60)
-
- // Handle previous day crossing
- if srHour < 0 {
- if let prevDay = calendar2.date(byAdding: .day, value: -1, to: date) {
- let prevComponents = calendar2.dateComponents([.year, .month, .day], from: prevDay)
- sunriseComp = prevComponents
- srHour = 24 + srHour
- }
- }
-
- sunriseComp.hour = max(0, min(23, srHour))
- sunriseComp.minute = max(0, min(59, srMinute))
- sunriseComp.second = 0
-
- // Sunset
- var sunsetComp = baseComponents
- var ssHour = Int(sunsetHour)
- var ssMinute = Int((sunsetHour - Double(Int(sunsetHour))) * 60)
-
- // Handle next day crossing
- if ssHour >= 24 {
- if let nextDay = calendar2.date(byAdding: .day, value: 1, to: date) {
- let nextComponents = calendar2.dateComponents([.year, .month, .day], from: nextDay)
- sunsetComp = nextComponents
- ssHour = ssHour - 24
- }
- }
-
- sunsetComp.hour = max(0, min(23, ssHour))
- sunsetComp.minute = max(0, min(59, ssMinute))
- sunsetComp.second = 0
-
- guard let sunriseDate = calendar2.date(from: sunriseComp),
- let sunsetDate = calendar2.date(from: sunsetComp) else {
- return nil
- }
-
- return (sunriseDate, sunsetDate)
- }
-
- private func isLeapYear(_ year: Int) -> Bool {
- return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
- }
-}
-```
-
-- [ ] **Step 4: Implement SunTimes (correct algorithm with day boundary handling)**
-
-Full implementation already shown above with corrected math.
-
-- [ ] **Step 5: Run tests to verify they pass**
-
-Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test -only SunTimesTests`
-Expected: PASS (3 test cases pass)
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add Solverv/Utilities/SunTimes.swift SolverVTests/Utilities/SunTimesTests.swift
-git commit -m "feat: add SunTimes calculator using NOAA algorithm with test-driven approach"
-```
-
----
-
-### Task 5: Create AppGroupManager
-
-**Files:**
-- Create: `Solverv/Utilities/AppGroupManager.swift`
-- Test: `SolverVTests/Utilities/AppGroupManagerTests.swift`
-
-- [ ] **Step 1: Write AppGroupManager**
-
-```swift
-// Solverv/Utilities/AppGroupManager.swift
-import Foundation
-
-class AppGroupManager {
- static let shared = AppGroupManager()
- static let appGroupID = "group.com.ivarlovlie.solverv"
-
- private lazy var userDefaults: UserDefaults? = {
- UserDefaults(suiteName: Self.appGroupID)
- }()
-
- // MARK: - Location Storage
-
- struct UserLocation: Codable {
- let latitude: Double
- let longitude: Double
- let timestamp: String // ISO 8601
- let isDefaultLocation: Bool
- }
-
- func saveLocation(_ location: UserLocation) {
- guard let ud = userDefaults else { return }
- if let encoded = try? JSONEncoder().encode(location) {
- ud.set(encoded, forKey: "userLocation")
- }
- }
-
- func getLocation() -> UserLocation? {
- guard let ud = userDefaults,
- let data = ud.data(forKey: "userLocation"),
- let location = try? JSONDecoder().decode(UserLocation.self, from: data) else {
- return nil
- }
- return location
- }
-
- // MARK: - Sunrise/Sunset Storage
-
- struct SunTimes: Codable {
- let date: String // ISO 8601 date only (YYYY-MM-DD)
- let sunrise: String // ISO 8601 datetime
- let sunset: String // ISO 8601 datetime
- let timestamp: String // ISO 8601 when calculated
- }
-
- func saveSunTimes(_ sunTimes: SunTimes) {
- guard let ud = userDefaults else { return }
- if let encoded = try? JSONEncoder().encode(sunTimes) {
- ud.set(encoded, forKey: "sunTimes")
- }
- }
-
- func getSunTimes() -> SunTimes? {
- guard let ud = userDefaults,
- let data = ud.data(forKey: "sunTimes"),
- let sunTimes = try? JSONDecoder().decode(SunTimes.self, from: data) else {
- return nil
- }
- return sunTimes
- }
-
- // MARK: - Helpers
-
- func clearAllData() {
- userDefaults?.removeObject(forKey: "userLocation")
- userDefaults?.removeObject(forKey: "sunTimes")
- }
-}
-```
-
-- [ ] **Step 2: Write test for AppGroupManager**
-
-```swift
-// SolverVTests/Utilities/AppGroupManagerTests.swift
-import XCTest
-@testable import Solverv
-
-final class AppGroupManagerTests: XCTestCase {
- var manager: AppGroupManager!
-
- override func setUp() {
- super.setUp()
- manager = AppGroupManager()
- manager.clearAllData()
- }
-
- override func tearDown() {
- super.tearDown()
- manager.clearAllData()
- }
-
- func testSaveAndRetrieveLocation() {
- let location = AppGroupManager.UserLocation(
- latitude: 59.9139,
- longitude: 10.7522,
- timestamp: "2026-03-23T10:00:00Z",
- isDefaultLocation: false
- )
-
- manager.saveLocation(location)
- let retrieved = manager.getLocation()
-
- XCTAssertNotNil(retrieved)
- XCTAssertEqual(retrieved?.latitude, 59.9139)
- XCTAssertEqual(retrieved?.longitude, 10.7522)
- }
-
- func testSaveAndRetrieveSunTimes() {
- let sunTimes = AppGroupManager.SunTimes(
- date: "2026-03-20",
- sunrise: "2026-03-20T06:42:00",
- sunset: "2026-03-20T18:15:00",
- timestamp: "2026-03-23T10:00:00Z"
- )
-
- manager.saveSunTimes(sunTimes)
- let retrieved = manager.getSunTimes()
-
- XCTAssertNotNil(retrieved)
- XCTAssertEqual(retrieved?.date, "2026-03-20")
- }
-
- func testClearData() {
- let location = AppGroupManager.UserLocation(
- latitude: 0.0, longitude: 0.0, timestamp: "2026-03-23T10:00:00Z", isDefaultLocation: true
- )
- manager.saveLocation(location)
- manager.clearAllData()
-
- XCTAssertNil(manager.getLocation())
- }
-}
-```
-
-- [ ] **Step 3: Run tests**
-
-Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test`
-Expected: Tests pass
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add Solverv/Utilities/AppGroupManager.swift SolverVTests/Utilities/AppGroupManagerTests.swift
-git commit -m "feat: add AppGroupManager for widget-app data syncing"
-```
-
----
-
-### Task 5.5: Verify SolvervDef Has Required Methods
-
-**Files:**
-- Modify: `Solsnu.Widget/Solsnu_Widget.swift`
-
-- [ ] **Step 1: Check SolvervDef methods exist**
-
-In Xcode, open `Solsnu.Widget/Solsnu_Widget.swift` and verify these methods exist on `SolvervDef`:
-- `nextEvent()` → SolsticeEvent?
-- `daysUntilNext()` → Int
-- `progressRatio()` → Double
-- `upcomingEventsPreview(count: Int)` → [SolsticeEvent]
-- `season` → Season
-
-If missing, add stub methods:
-
-```swift
-extension SolvervDef {
- var season: Season {
- // Will be implemented in Task 7
- return .winter
- }
-
- func daysUntilNext() -> Int {
- return 0
- }
-
- func progressRatio() -> Double {
- return 0.0
- }
-
- func upcomingEventsPreview(count: Int) -> [SolsticeEvent] {
- return []
- }
-}
-```
-
-- [ ] **Step 2: Run intermediate build**
-
-Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build`
-Expected: Build succeeds (allows widget code to compile before full implementation)
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add Solsnu.Widget/Solsnu_Widget.swift
-git commit -m "chore: add SolvervDef stub methods for widget integration"
-```
-
----
-
-### Task 6: Create Widget Views (Small, Medium, Large)
-
-**Files:**
-- Create: `Solsnu.Widget/Views/SmallWidgetView.swift`
-- Create: `Solsnu.Widget/Views/MediumWidgetView.swift`
-- Create: `Solsnu.Widget/Views/LargeWidgetView.swift`
-
-- [ ] **Step 1: Write SmallWidgetView**
-
-```swift
-// Solsnu.Widget/Views/SmallWidgetView.swift
-import SwiftUI
-import WidgetKit
-
-struct SmallWidgetView: View {
- let entry: SolvervEntry
- @Environment(\.widgetRenderingMode) var renderingMode
-
- var body: some View {
- VStack(spacing: 8) {
- // Seasonal image
- Image(entry.def.season.assetName)
- .resizable()
- .scaledToFit()
- .frame(maxHeight: .infinity)
-
- // Event name
- Text(entry.def.nextEvent?.name ?? "Loading...")
- .font(.caption)
- .lineLimit(1)
-
- // Days countdown
- Text("\(entry.def.daysUntilNext())")
- .font(.system(.title, design: .default).weight(.bold))
- .foregroundColor(entry.def.season.colorLight)
-
- Text("days")
- .font(.caption2)
- }
- .padding()
- }
-}
-
-#Preview(as: .systemSmall) {
- let entry = SolvervEntry(def: SolvervDef.preview)
- SmallWidgetView(entry: entry)
-}
-```
-
-- [ ] **Step 2: Write MediumWidgetView**
-
-```swift
-// Solsnu.Widget/Views/MediumWidgetView.swift
-import SwiftUI
-import WidgetKit
-
-struct MediumWidgetView: View {
- let entry: SolvervEntry
-
- var body: some View {
- HStack(spacing: 12) {
- // Left: Image
- Image(entry.def.season.assetName)
- .resizable()
- .scaledToFit()
- .frame(width: 120, height: 120)
-
- // Right: Info
- VStack(alignment: .leading, spacing: 8) {
- Text(entry.def.nextEvent?.name ?? "Loading...")
- .font(.headline)
- .lineLimit(1)
-
- Text("\(entry.def.daysUntilNext()) days")
- .font(.system(.title3, design: .default).weight(.semibold))
- .foregroundColor(entry.def.season.colorLight)
-
- ProgressView(value: Double(entry.def.progressRatio()))
- .tint(entry.def.season.colorLight)
-
- Spacer()
- }
-
- Spacer()
- }
- .padding()
- }
-}
-
-#Preview(as: .systemMedium) {
- let entry = SolvervEntry(def: SolvervDef.preview)
- MediumWidgetView(entry: entry)
-}
-```
-
-- [ ] **Step 3: Write LargeWidgetView**
-
-```swift
-// Solsnu.Widget/Views/LargeWidgetView.swift
-import SwiftUI
-import WidgetKit
-
-struct LargeWidgetView: View {
- let entry: SolvervEntry
-
- var body: some View {
- VStack(spacing: 12) {
- // Top: Image
- Image(entry.def.season.assetName)
- .resizable()
- .scaledToFill()
- .frame(height: 140)
- .clipped()
-
- // Bottom: Info
- VStack(alignment: .leading, spacing: 10) {
- // Event name and countdown
- VStack(alignment: .leading, spacing: 4) {
- Text(entry.def.nextEvent?.name ?? "Loading...")
- .font(.headline)
-
- Text("\(entry.def.daysUntilNext()) days")
- .font(.system(.title2, design: .default).weight(.bold))
- .foregroundColor(entry.def.season.colorLight)
- }
-
- // Progress bar
- ProgressView(value: Double(entry.def.progressRatio()))
- .tint(entry.def.season.colorLight)
-
- Divider()
-
- // Next 3 events preview
- VStack(alignment: .leading, spacing: 6) {
- Text("Upcoming")
- .font(.caption)
- .foregroundColor(.secondary)
-
- ForEach(entry.def.upcomingEventsPreview(count: 3), id: \.id) { event in
- HStack {
- Circle()
- .fill(event.season.colorLight)
- .frame(width: 8, height: 8)
-
- Text(event.name)
- .font(.caption)
-
- Spacer()
-
- Text("\(event.daysUntil())d")
- .font(.caption2)
- .foregroundColor(.secondary)
- }
- }
- }
-
- Spacer()
- }
- .padding(.horizontal)
- .padding(.vertical, 8)
- }
- }
-}
-
-#Preview(as: .systemLarge) {
- let entry = SolvervEntry(def: SolvervDef.preview)
- LargeWidgetView(entry: entry)
-}
-```
-
-- [ ] **Step 4: Verify widgets compile**
-
-Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build`
-Expected: Build succeeds
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add Solsnu.Widget/Views/
-git commit -m "feat: add widget views for small, medium, and large sizes"
-```
-
----
-
-### Task 7: Update SolvervDef and Widget Provider
-
-**Files:**
-- Modify: `Solsnu.Widget/Solsnu_Widget.swift`
-
-- [ ] **Step 1: Update SolvervDef with required methods**
-
-Add to `SolvervDef` in `Solsnu_Widget.swift`:
-
-```swift
-extension SolvervDef {
- var season: Season {
- guard let next = nextEvent else { return .winter }
- return next.season
- }
-
- var nextEvent: SolsticeEvent? {
- SolsticeData.shared.nextEvent()
- }
-
- func daysUntilNext() -> Int {
- guard let next = nextEvent else { return 0 }
- return next.daysUntil()
- }
-
- func progressRatio() -> Double {
- guard let progress = SolsticeData.shared.progressToNextEvent() else { return 0.0 }
- let ratio = Double(progress.elapsed) / Double(progress.total)
- return max(0, min(1.0, ratio))
- }
-
- func upcomingEventsPreview(count: Int) -> [SolsticeEvent] {
- SolsticeData.shared.upcomingEvents(count: count)
- }
-
- static var preview: SolvervDef {
- SolvervDef(date: Date())
- }
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add Solsnu.Widget/Solsnu_Widget.swift
-git commit -m "feat: extend SolvervDef with solstice calculations"
-```
-
----
-
-### Task 8: Create Main App Info Screen
-
-**Files:**
-- Create: `Solverv/Views/InfoScreenView.swift`
-- Modify: `Solverv/ContentView.swift`
-
-- [ ] **Step 1: Write InfoScreenView**
-
-```swift
-// Solverv/Views/InfoScreenView.swift
-import SwiftUI
-
-struct InfoScreenView: View {
- @State private var upcomingEvents: [SolsticeEvent] = []
- @State private var nextEvent: SolsticeEvent?
- @State private var progressData: (elapsed: Int, total: Int)? = nil
- @State private var sunriseTime: Date?
- @State private var sunsetTime: Date?
-
- var body: some View {
- ScrollView {
- VStack(spacing: 24) {
- // Top section: Image + countdown
- VStack(spacing: 16) {
- if let next = nextEvent {
- Image(next.season.assetName)
- .resizable()
- .scaledToFit()
- .frame(height: 200)
- .clipped()
-
- VStack(spacing: 8) {
- Text(next.name)
- .font(.title3)
-
- Text("\(next.daysUntil())")
- .font(.system(.title, design: .default).weight(.bold))
- .foregroundColor(next.season.colorLight)
-
- Text("days remaining")
- .font(.caption)
- .foregroundColor(.secondary)
- }
-
- if let progress = progressData {
- ProgressView(value: Double(progress.elapsed) / Double(progress.total))
- .tint(next.season.colorLight)
- }
- }
- }
- .padding()
- .background(Color(.systemGray6))
- .cornerRadius(12)
-
- // Middle section: Sun times + season info
- VStack(spacing: 16) {
- VStack(alignment: .leading, spacing: 12) {
- Text("Today's Sun Times")
- .font(.headline)
-
- HStack {
- Label("Sunrise", systemImage: "sunrise.fill")
- Spacer()
- if let sunrise = sunriseTime {
- Text(sunrise, style: .time)
- } else {
- Text("—").foregroundColor(.secondary)
- }
- }
-
- HStack {
- Label("Sunset", systemImage: "sunset.fill")
- Spacer()
- if let sunset = sunsetTime {
- Text(sunset, style: .time)
- } else {
- Text("—").foregroundColor(.secondary)
- }
- }
- }
-
- Divider()
-
- if let next = nextEvent {
- VStack(alignment: .leading, spacing: 8) {
- Text("Current Season")
- .font(.headline)
-
- HStack(spacing: 8) {
- Circle()
- .fill(next.season.colorLight)
- .frame(width: 12, height: 12)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(next.season.displayName)
- .font(.subheadline)
-
- Text(next.season.description)
- .font(.caption)
- .foregroundColor(.secondary)
- }
- }
- }
- }
- }
- .padding()
- .background(Color(.systemGray6))
- .cornerRadius(12)
-
- // Bottom section: Upcoming events list
- VStack(alignment: .leading, spacing: 12) {
- Text("Upcoming Events")
- .font(.headline)
-
- ForEach(upcomingEvents, id: \.id) { event in
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- Text(event.name)
- .font(.subheadline)
- .lineLimit(1)
-
- let formatter = DateFormatter()
- formatter.dateStyle = .short
- formatter.timeStyle = .short
- formatter.timeZone = .current
-
- Text(formatter.string(from: event.localDateTime()))
- .font(.caption)
- .foregroundColor(.secondary)
- }
-
- Spacer()
-
- VStack(alignment: .trailing, spacing: 2) {
- Text("\(event.daysUntil())d")
- .font(.subheadline)
-
- Circle()
- .fill(event.season.colorLight)
- .frame(width: 12, height: 12)
- }
- }
- .padding(.vertical, 8)
-
- if event.id != upcomingEvents.last?.id {
- Divider()
- }
- }
- }
- .padding()
- .background(Color(.systemGray6))
- .cornerRadius(12)
- }
- .padding()
- }
- .navigationTitle("Solstices & Equinoxes")
- .onAppear(perform: loadData)
- .onReceive(Timer.publish(every: 60).autoconnect()) { _ in
- loadData()
- }
- }
-
- private func loadData() {
- nextEvent = SolsticeData.shared.nextEvent()
- upcomingEvents = SolsticeData.shared.upcomingEvents(count: 12)
- progressData = SolsticeData.shared.progressToNextEvent()
-
- // Load sunrise/sunset from AppGroup or calculate
- if let location = AppGroupManager.shared.getLocation() {
- let sunTimes = SunTimes(
- latitude: location.latitude,
- longitude: location.longitude,
- date: Date()
- )
- sunriseTime = sunTimes.sunrise()
- sunsetTime = sunTimes.sunset()
- }
- }
-}
-
-#Preview {
- NavigationStack {
- InfoScreenView()
- }
-}
-```
-
-- [ ] **Step 2: Update ContentView to include InfoScreen in navigation**
-
-Replace `ContentView.swift`:
-
-```swift
-// Solverv/ContentView.swift
-import SwiftUI
-
-struct ContentView: View {
- var body: some View {
- NavigationStack {
- VStack {
- Text("Solstice Countdown")
- .font(.title)
-
- NavigationLink(destination: InfoScreenView()) {
- Label("View Details", systemImage: "info.circle")
- .padding()
- .background(Color.blue)
- .foregroundColor(.white)
- .cornerRadius(8)
- }
- }
- .navigationTitle("Home")
- }
- }
-}
-
-#Preview {
- ContentView()
-}
-```
-
-- [ ] **Step 3: Verify compilation**
-
-Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build`
-Expected: Build succeeds
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add Solverv/Views/InfoScreenView.swift Solverv/ContentView.swift
-git commit -m "feat: add InfoScreenView with upcoming events and sun times"
-```
-
----
-
-### Task 9: Request Location Permission in App Delegate
-
-**Files:**
-- Modify: `Solverv/SolvervApp.swift`
-
-- [ ] **Step 1: Update SolvervApp to request location permission**
-
-```swift
-// Solverv/SolvervApp.swift
-import SwiftUI
-import SwiftData
-import CoreLocation
-
-@main
-struct SolvervApp: App {
- @State private var locationManager = LocationManager()
-
- var sharedModelContainer: ModelContainer = {
- let schema = Schema([Item.self])
- let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
-
- do {
- return try ModelContainer(for: schema, configurations: [modelConfiguration])
- } catch {
- fatalError("Could not create ModelContainer: \(error)")
- }
- }()
-
- var body: some Scene {
- WindowGroup {
- ContentView()
- }
- .modelContainer(sharedModelContainer)
- .onAppear {
- locationManager.requestLocationPermission()
- }
- }
-}
-
-class LocationManager: NSObject, CLLocationManagerDelegate, ObservableObject {
- let manager = CLLocationManager()
-
- override init() {
- super.init()
- manager.delegate = self
- }
-
- func requestLocationPermission() {
- if manager.authorizationStatus == .notDetermined {
- manager.requestWhenInUseAuthorization()
- } else if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways {
- fetchLocation()
- } else {
- // Use default location (Greenwich)
- let defaultLocation = AppGroupManager.UserLocation(
- latitude: 0.0,
- longitude: 0.0,
- timestamp: ISO8601DateFormatter().string(from: Date()),
- isDefaultLocation: true
- )
- AppGroupManager.shared.saveLocation(defaultLocation)
- }
- }
-
- func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
- if status == .authorizedWhenInUse || status == .authorizedAlways {
- fetchLocation()
- }
- }
-
- private func fetchLocation() {
- manager.startUpdatingLocation()
- }
-
- func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
- guard let location = locations.last else { return }
-
- let userLocation = AppGroupManager.UserLocation(
- latitude: location.coordinate.latitude,
- longitude: location.coordinate.longitude,
- timestamp: ISO8601DateFormatter().string(from: Date()),
- isDefaultLocation: false
- )
- AppGroupManager.shared.saveLocation(userLocation)
-
- // Calculate and cache sunrise/sunset
- let sunTimes = SunTimes(
- latitude: location.coordinate.latitude,
- longitude: location.coordinate.longitude,
- date: Date()
- )
-
- if let sunrise = sunTimes.sunrise(), let sunset = sunTimes.sunset() {
- let formatter = ISO8601DateFormatter()
- let cachedTimes = AppGroupManager.SunTimes(
- date: formatter.string(from: Date()).prefix(10).description,
- sunrise: formatter.string(from: sunrise),
- sunset: formatter.string(from: sunset),
- timestamp: formatter.string(from: Date())
- )
- AppGroupManager.shared.saveSunTimes(cachedTimes)
- }
-
- manager.stopUpdatingLocation()
- }
-}
-```
-
-- [ ] **Step 2: Add location permission description to Info.plist**
-
-In Xcode, open `Solverv/Info.plist` and add:
-- Key: `NSLocationWhenInUseUsageDescription`
-- Value: `"We need your location to calculate sunrise and sunset times for your area."`
-
-- [ ] **Step 3: Verify compilation**
-
-Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build`
-Expected: Build succeeds
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add Solverv/SolvervApp.swift Solverv/Info.plist
-git commit -m "feat: add location permission request and caching"
-```
-
----
-
-### Task 10: Add Image Assets to Xcode Project
-
-**Files:**
-- Create: Asset images in `Solverv/Assets.xcassets`
-
-- [ ] **Step 1: Create Color Sets in Assets.xcassets**
-
-In Xcode:
-1. Open `Solverv/Assets.xcassets`
-2. Click `+` at bottom → "New Color Set"
-3. Create 4 color sets with names: `SeasonSpring`, `SeasonSummer`, `SeasonAutumn`, `SeasonWinter`
-4. For each, set the color:
- - Spring: RGB (76, 175, 80)
- - Summer: RGB (255, 193, 7)
- - Autumn: RGB (255, 152, 0)
- - Winter: RGB (33, 150, 243)
-5. Enable dark mode variants in Attributes Inspector
-6. Set dark mode colors to a slightly adjusted version or same color
-
-Alternative: Create placeholder image sets with simple colored squares:
-- Each should be 1024×1024 PNG
-- Name: `SeasonSpring`, `SeasonSummer`, `SeasonAutumn`, `SeasonWinter`
-- Include 1x, 2x, 3x variants
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add Solverv/Assets.xcassets/
-git commit -m "feat: add season image assets for widget and app"
-```
-
----
-
-### Task 11: Update Widget Bundle and Configure Multiple Sizes
-
-**Files:**
-- Modify: `Solsnu.Widget/Solsnu_WidgetBundle.swift`
-
-- [ ] **Step 1: Update widget bundle to support all sizes**
-
-```swift
-// Solsnu.Widget/Solsnu_WidgetBundle.swift
-import WidgetKit
-import SwiftUI
-
-@main
-struct Solsnu_WidgetBundle {
- var body: some Widget {
- Solsnu_Widget()
- Solsnu_WidgetMedium()
- Solsnu_WidgetLarge()
- }
-}
-
-// Small widget (default)
-struct Solsnu_Widget: Widget {
- let kind: String = "Solsnu_Widget_Small"
-
- var body: some WidgetConfiguration {
- StaticConfiguration(kind: kind, provider: Provider()) { entry in
- if #available(iOS 17.0, *) {
- SmallWidgetView(entry: entry)
- .containerBackground(.fill.tertiary, for: .widget)
- } else {
- SmallWidgetView(entry: entry)
- .padding()
- .background()
- }
- }
- .configurationDisplayName("Solstice Countdown")
- .description("Days until next solstice or equinox")
- .supportedFamilies([.systemSmall])
- }
-}
-
-// Medium widget
-struct Solsnu_WidgetMedium: Widget {
- let kind: String = "Solsnu_Widget_Medium"
-
- var body: some WidgetConfiguration {
- StaticConfiguration(kind: kind, provider: Provider()) { entry in
- if #available(iOS 17.0, *) {
- MediumWidgetView(entry: entry)
- .containerBackground(.fill.tertiary, for: .widget)
- } else {
- MediumWidgetView(entry: entry)
- .padding()
- .background()
- }
- }
- .configurationDisplayName("Solstice Countdown")
- .description("Days until next solstice or equinox")
- .supportedFamilies([.systemMedium])
- }
-}
-
-// Large widget
-struct Solsnu_WidgetLarge: Widget {
- let kind: String = "Solsnu_Widget_Large"
-
- var body: some WidgetConfiguration {
- StaticConfiguration(kind: kind, provider: Provider()) { entry in
- if #available(iOS 17.0, *) {
- LargeWidgetView(entry: entry)
- .containerBackground(.fill.tertiary, for: .widget)
- } else {
- LargeWidgetView(entry: entry)
- .padding()
- .background()
- }
- }
- .configurationDisplayName("Solstice Countdown")
- .description("Days until next solstice or equinox")
- .supportedFamilies([.systemLarge])
- }
-}
-```
-
-- [ ] **Step 2: Verify compilation**
-
-Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build`
-Expected: Build succeeds
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add Solsnu.Widget/Solsnu_WidgetBundle.swift
-git commit -m "feat: add medium and large widget configurations"
-```
-
----
-
-### Task 12: Update Widget Provider Timeline Refresh
-
-**Files:**
-- Modify: `Solsnu.Widget/Solsnu_Widget.swift` (Provider section)
-
-- [ ] **Step 1: Update Provider to use midnight refresh**
-
-Replace the `Provider` struct in `Solsnu_Widget.swift`:
-
-```swift
-struct Provider: TimelineProvider {
- func placeholder(in context: Context) -> SolvervEntry {
- SolvervEntry(def: SolvervDef(date: Date()))
- }
-
- func getSnapshot(in context: Context, completion: @escaping (SolvervEntry) -> ()) {
- let entry = SolvervEntry(def: SolvervDef(date: Date()))
- completion(entry)
- }
-
- func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
- var entries: [SolvervEntry] = []
- let currentDate = Date()
-
- // Single entry for today
- let entry = SolvervEntry(def: SolvervDef(date: currentDate))
- entries.append(entry)
-
- // Calculate next midnight for refresh
- let calendar = Calendar.current
- var components = calendar.dateComponents([.year, .month, .day], from: currentDate)
- components.hour = 0
- components.minute = 0
- components.second = 0
- let todayMidnight = calendar.date(from: components)!
- let nextMidnight = calendar.date(byAdding: .day, value: 1, to: todayMidnight)!
-
- let timeline = Timeline(entries: entries, policy: .after(nextMidnight))
- completion(timeline)
- }
-}
-```
-
-- [ ] **Step 2: Verify compilation**
-
-Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build`
-Expected: Build succeeds
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add Solsnu.Widget/Solsnu_Widget.swift
-git commit -m "feat: update widget timeline to refresh at midnight"
-```
-
----
-
-### Task 13: Run All Tests and Verify Build
-
-**Files:**
-- All modified files
-
-- [ ] **Step 1: Run all unit tests**
-
-Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test`
-Expected: All tests pass (SolsticeDataTests, SunTimesTests, AppGroupManagerTests)
-
-- [ ] **Step 2: Build main app**
-
-Run: `xcodebuild -scheme Solverv -destination 'generic/platform=iOS' build`
-Expected: Build succeeds
-
-- [ ] **Step 3: Build widget**
-
-Run: `xcodebuild -scheme Solsnu_Widget -destination 'generic/platform=iOS' build`
-Expected: Build succeeds
-
-- [ ] **Step 4: Commit test results**
-
-```bash
-git add -A
-git commit -m "test: verify all tests pass and builds succeed"
-```
-
----
-
-## Summary
-
-This plan delivers:
-
-✅ **Core Models:** SolsticeEvent, SolsticeData, Season with full calculations
-✅ **Utilities:** SunTimes (NOAA algorithm), AppGroupManager for data syncing
-✅ **Widget Views:** Small (169×169), Medium (364×169), Large (364×364) with responsive layouts
-✅ **Main App:** InfoScreenView showing upcoming events, sun times, progress
-✅ **Location Permission:** Automatic request on launch with default fallback
-✅ **Image Assets:** Season-specific imagery in dark/light modes
-✅ **Data Persistence:** AppGroup container for widget-app sync
-✅ **Testing:** Unit tests for core calculations with expected behavior verification
-✅ **Timeline Refresh:** Widget refreshes daily at midnight
-
-**Total Tasks:** 13 logical implementation steps
-**Estimated Commits:** ~20 (one per meaningful change)
-
----
diff --git a/docs/superpowers/plans/2026-03-23-sunrise-sunset-widget-implementation.md b/docs/superpowers/plans/2026-03-23-sunrise-sunset-widget-implementation.md
deleted file mode 100644
index 19c36fb..0000000
--- a/docs/superpowers/plans/2026-03-23-sunrise-sunset-widget-implementation.md
+++ /dev/null
@@ -1,539 +0,0 @@
-# Sunrise/Sunset Widget Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Display sunrise and sunset times in HH:mm format on small and medium widgets, calculated locally using the user's location.
-
-**Architecture:** Widget Provider fetches cached user location from AppGroupManager, calculates sunrise/sunset times using the existing SunTimes utility, and passes formatted times to widget views. Gracefully handles unavailable locations by showing no times.
-
-**Tech Stack:**
-- SwiftUI WidgetKit (existing)
-- SunTimes (NOAA algorithm, already available)
-- AppGroupManager (existing shared storage)
-
----
-
-## File Structure
-
-**Files to Modify:**
-- `Solsnu.Widget/Solsnu_Widget.swift` — Add time properties to SolvervDef, update Provider logic
-- `Solsnu.Widget/Views/SmallWidgetView.swift` — Display sunrise/sunset if available
-- `Solsnu.Widget/Views/MediumWidgetView.swift` — Display sunrise/sunset if available
-
-**Files to Create:**
-- None (all infrastructure already exists)
-
----
-
-## Task 1: Add Properties to SolvervDef
-
-**Files:**
-- Modify: `Solsnu.Widget/Solsnu_Widget.swift:72-90`
-
-- [ ] **Step 1: Add sunrise/sunset Date properties to SolvervDef struct**
-
-Add these properties after `bg: String`:
-```swift
-let sunriseTime: Date?
-let sunsetTime: Date?
-```
-
-- [ ] **Step 2: Update existing constructors to accept nil times**
-
-Modify `init(date:Date)`:
-```swift
-init(date: Date, sunriseTime: Date? = nil, sunsetTime: Date? = nil) {
- self.date = date
- self.sunriseTime = sunriseTime
- self.sunsetTime = sunsetTime
- self.bg = "smallbg"
-}
-```
-
-Modify `init(utcString:String)`:
-```swift
-init(utcString: String, sunriseTime: Date? = nil, sunsetTime: Date? = nil) {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
- formatter.timeZone = TimeZone(abbreviation: "UTC")
- let date = formatter.date(from: utcString)!
- self.date = date
- self.sunriseTime = sunriseTime
- self.sunsetTime = sunsetTime
- self.bg = "smallbg"
-}
-```
-
-- [ ] **Step 3: Add computed properties for formatted times**
-
-Add after `bg: String` property declarations:
-```swift
-var sunriseFormatted: String {
- guard let time = sunriseTime else { return "" }
- let formatter = DateFormatter()
- formatter.timeStyle = .short
- formatter.dateStyle = .none
- return formatter.string(from: time)
-}
-
-var sunsetFormatted: String {
- guard let time = sunsetTime else { return "" }
- let formatter = DateFormatter()
- formatter.timeStyle = .short
- formatter.dateStyle = .none
- return formatter.string(from: time)
-}
-```
-
-- [ ] **Step 4: Verify compilation**
-
-Build the widget target:
-```bash
-xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS"
-```
-
-Expected: Build succeeds with no errors
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add Solsnu.Widget/Solsnu_Widget.swift
-git commit -m "feat: add sunrise/sunset properties to SolvervDef"
-```
-
----
-
-## Task 2: Update Provider to Fetch and Calculate Sun Times
-
-**Files:**
-- Modify: `Solsnu.Widget/Solsnu_Widget.swift:11-37` (Provider struct and getTimeline method)
-
-- [ ] **Step 1: Import AppGroupManager in widget**
-
-Add to imports at top of Solsnu_Widget.swift:
-```swift
-import WidgetKit
-import SwiftUI
-// Add this:
-import Combine
-```
-
-Note: Check if you need to add AppGroupManager to the widget target. It may require adding the file to the widget's Build Phases.
-
-- [ ] **Step 2: Rewrite Provider.getTimeline() to fetch location**
-
-Replace the entire `getTimeline` method with:
-```swift
-func getTimeline(in context: Context, completion: @escaping (Timeline<SolvervEntry>) -> ()) {
- var entries: [SolvervEntry] = []
- let currentDate = Date()
-
- // Fetch location from AppGroupManager
- let location = AppGroupManager.shared.getLocation()
-
- var sunriseTime: Date? = nil
- var sunsetTime: Date? = nil
-
- // If location exists and is less than 24 hours old, calculate sun times
- if let location = location {
- let locationFormatter = ISO8601DateFormatter()
- if let locationTimestamp = locationFormatter.date(from: location.timestamp) {
- let hoursSinceLocation = currentDate.timeIntervalSince(locationTimestamp) / 3600.0
-
- if hoursSinceLocation < 24.0 {
- // Calculate sun times using SunTimes utility
- let calculator = SunTimes(
- latitude: location.latitude,
- longitude: location.longitude,
- date: currentDate
- )
- sunriseTime = calculator.sunrise()
- sunsetTime = calculator.sunset()
- }
- }
- }
-
- // Create entry with calculated times
- let entry = SolvervEntry(
- def: SolvervDef(
- date: currentDate,
- sunriseTime: sunriseTime,
- sunsetTime: sunsetTime
- )
- )
- entries.append(entry)
-
- // Refresh at next midnight
- let calendar = Calendar.current
- var components = calendar.dateComponents([.year, .month, .day], from: currentDate)
- components.hour = 0
- components.minute = 0
- components.second = 0
- let todayMidnight = calendar.date(from: components)!
- let nextMidnight = calendar.date(byAdding: .day, value: 1, to: todayMidnight)!
-
- let timeline = Timeline(entries: entries, policy: .after(nextMidnight))
- completion(timeline)
-}
-```
-
-- [ ] **Step 3: Verify Provider needs SunTimes import**
-
-At the top of Solsnu_Widget.swift, verify you have:
-```swift
-import WidgetKit
-import SwiftUI
-```
-
-Note: SunTimes is in the main app target. You may need to either:
-- Move SunTimes to a shared framework, OR
-- Copy/duplicate SunTimes into the widget target, OR
-- Add SunTimes file to widget's Build Phases
-
-Check your project setup. If SunTimes isn't accessible to the widget, you'll need to add it to the widget's Build Phases.
-
-- [ ] **Step 4: Build and verify no errors**
-
-```bash
-xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS"
-```
-
-Expected: Build succeeds
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add Solsnu.Widget/Solsnu_Widget.swift
-git commit -m "feat: add location fetching and sun time calculation to widget provider"
-```
-
----
-
-## Task 3: Update SmallWidgetView to Display Times
-
-**Files:**
-- Modify: `Solsnu.Widget/Views/SmallWidgetView.swift`
-
-- [ ] **Step 1: Understand current SmallWidgetView layout**
-
-Read the file to see how it currently displays the countdown
-
-- [ ] **Step 2: Add sunrise/sunset display**
-
-Update `body` to include times if available:
-
-```swift
-var body: some View {
- ZStack {
- Image(entry.def.bg)
- .resizable()
- .scaledToFill()
-
- VStack(spacing: 8) {
- // Days until next event (existing)
- Text("\(entry.def.daysUntilNext())")
- .font(.system(size: 26, weight: .bold, design: .serif))
- .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
- .italic()
-
- // Sunrise/Sunset times (new)
- if !entry.def.sunriseFormatted.isEmpty && !entry.def.sunsetFormatted.isEmpty {
- HStack(spacing: 4) {
- Text("↑ \(entry.def.sunriseFormatted)")
- Text("↓ \(entry.def.sunsetFormatted)")
- }
- .font(.system(size: 11, weight: .regular))
- .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
- }
- }
- .position(x: 50, y: 50)
- }
- .containerBackground(for: .widget, alignment: .center) { Color.clear }
-}
-```
-
-- [ ] **Step 3: Build and preview**
-
-Build the widget:
-```bash
-xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS"
-```
-
-Expected: Build succeeds
-
-Check the preview in Xcode to see the new layout with times (if available)
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add Solsnu.Widget/Views/SmallWidgetView.swift
-git commit -m "feat: display sunrise/sunset times in small widget"
-```
-
----
-
-## Task 4: Update MediumWidgetView to Display Times
-
-**Files:**
-- Modify: `Solsnu.Widget/Views/MediumWidgetView.swift`
-
-- [ ] **Step 1: Read current MediumWidgetView**
-
-Understand its current structure
-
-- [ ] **Step 2: Add sunrise/sunset display**
-
-Add the times to the medium widget view. Example approach:
-
-```swift
-var body: some View {
- ZStack {
- Image(entry.def.bg)
- .resizable()
- .scaledToFill()
-
- VStack(alignment: .leading, spacing: 12) {
- // Existing countdown content
- Text("\(entry.def.daysUntilNext())")
- .font(.system(size: 32, weight: .bold, design: .serif))
- .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
-
- // Sunrise/Sunset times
- if !entry.def.sunriseFormatted.isEmpty && !entry.def.sunsetFormatted.isEmpty {
- HStack {
- Text("Sunrise: \(entry.def.sunriseFormatted)")
- Spacer()
- Text("Sunset: \(entry.def.sunsetFormatted)")
- }
- .font(.system(size: 12, weight: .regular))
- .foregroundStyle(Color(red: 0.152, green: 0.136, blue: 0.056))
- }
- }
- .padding()
- }
- .containerBackground(for: .widget, alignment: .topLeading) { Color.clear }
-}
-```
-
-(Adjust layout to match your design preferences)
-
-- [ ] **Step 3: Build and preview**
-
-```bash
-xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS"
-```
-
-Expected: Build succeeds
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add Solsnu.Widget/Views/MediumWidgetView.swift
-git commit -m "feat: display sunrise/sunset times in medium widget"
-```
-
----
-
-## Task 5: Handle SunTimes Availability in Widget
-
-**Files:**
-- Modify: `Solsnu.Widget/Solsnu_Widget.swift` (if SunTimes is not available)
-
-**Note:** This task only applies if SunTimes cannot be imported directly into the widget. If you got build errors in Task 2 Step 3, you need to copy SunTimes into the widget target.
-
-- [ ] **Step 1: Check if SunTimes is available in widget**
-
-Try to build:
-```bash
-xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS" 2>&1 | grep -i suntimes
-```
-
-If no errors: Skip to Step 4
-If error: Proceed to Step 2
-
-- [ ] **Step 2: Copy SunTimes to widget target (if needed)**
-
-If build failed because SunTimes isn't accessible:
-
-Copy the file:
-```bash
-cp Solverv/Utilities/SunTimes.swift Solsnu.Widget/Utilities/SunTimes.swift
-```
-
-Create the Utilities folder in widget if needed:
-```bash
-mkdir -p Solsnu.Widget/Utilities
-```
-
-- [ ] **Step 3: Add SunTimes to widget target in Xcode**
-
-In Xcode:
-1. Open the project
-2. Select `Solsnu.Widget` target
-3. Go to Build Phases → Compile Sources
-4. Add `Solsnu.Widget/Utilities/SunTimes.swift`
-
-Or via command line:
-```bash
-# This is typically done in Xcode UI
-```
-
-Build again:
-```bash
-xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS"
-```
-
-Expected: Build succeeds
-
-- [ ] **Step 4: Commit (if changes made)**
-
-```bash
-git add Solsnu.Widget/Utilities/SunTimes.swift Solsnu.Widget/Solsnu_Widget.swift
-git commit -m "feat: add SunTimes calculator to widget target for sun time calculations"
-```
-
-If no changes needed, skip this commit.
-
----
-
-## Task 6: Integration Testing
-
-**Files:**
-- Test: Solsnu_Widget preview in Xcode
-
-- [ ] **Step 1: Test with valid location**
-
-In Solsnu_Widget.swift preview, modify the preview to include sample sun times:
-
-```swift
-#Preview(as: .systemSmall) {
- Solsnu_Widget()
-} timeline: {
- let formatter = DateFormatter()
- formatter.timeStyle = .short
- formatter.dateStyle = .none
-
- // Sample entry with sun times
- let springDate = Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 20))!
- let sunriseTime = Calendar.current.date(bySettingHour: 7, minute: 30, second: 0, of: springDate)!
- let sunsetTime = Calendar.current.date(bySettingHour: 19, minute: 45, second: 0, of: springDate)!
-
- SolvervEntry(
- def: SolvervDef(
- date: springDate,
- sunriseTime: sunriseTime,
- sunsetTime: sunsetTime
- )
- )
-}
-```
-
-Build the preview:
-```bash
-xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS"
-```
-
-- [ ] **Step 2: Verify widget displays times correctly**
-
-In Xcode, view the canvas/preview and confirm:
-- Sunrise time displays as HH:mm format
-- Sunset time displays as HH:mm format
-- Both times appear on the widget (layout may vary)
-
-- [ ] **Step 3: Test with nil times (no location)**
-
-Update preview to test with nil times:
-
-```swift
-SolvervEntry(
- def: SolvervDef(
- date: springDate,
- sunriseTime: nil,
- sunsetTime: nil
- )
-)
-```
-
-Build and verify:
-- Widget displays without crashing
-- No times are shown
-- Layout remains clean (no empty space for times)
-
-- [ ] **Step 4: Test different locations**
-
-Manually verify with sample calculations:
-- Oslo (59.9°N, 10.7°E): Should show reasonable spring times
-- Equator (0°, 0°): Should show ~6am and ~6pm
-- Near poles: May show nil (handled gracefully)
-
-- [ ] **Step 5: Commit testing changes (if any)**
-
-```bash
-git add Solsnu.Widget/Solsnu_Widget.swift
-git commit -m "test: add preview entries with sunrise/sunset times"
-```
-
----
-
-## Task 7: Final Integration & Verification
-
-**Files:**
-- Verify: All modified files
-
-- [ ] **Step 1: Run full build**
-
-```bash
-xcodebuild build -scheme Solverv -destination "generic/platform=iOS"
-xcodebuild build -scheme Solsnu_Widget -destination "generic/platform=iOS"
-```
-
-Expected: Both schemes build successfully with no warnings
-
-- [ ] **Step 2: Check widget refresh behavior**
-
-The widget should:
-- Refresh at midnight (existing policy maintained)
-- Show sun times if location is available and fresh (< 24 hours)
-- Show nothing for times if location is unavailable or stale
-
-This is verified automatically via the Provider logic.
-
-- [ ] **Step 3: Verify no regressions**
-
-Check that existing functionality still works:
-- Countdown still displays
-- Solstice events still calculate correctly
-- Widget background image still loads
-- Other widget configurations still work
-
-- [ ] **Step 4: Final commit and review**
-
-Review all changes:
-```bash
-git log --oneline -7
-```
-
-Should see commits for:
-1. Add sunrise/sunset properties to SolvervDef
-2. Add location fetching and sun time calculation to widget provider
-3. Display sunrise/sunset times in small widget
-4. Display sunrise/sunset times in medium widget
-5. (Optional) Add SunTimes to widget target
-6. (Optional) Add preview entries
-
-All changes complete ✅
-
----
-
-## Notes
-
-- **AppGroupManager Dependency:** The widget must be able to access AppGroupManager to read cached location. Ensure both the main app and widget targets have access to this file or share it via a framework.
-
-- **SunTimes Availability:** The SunTimes calculator must be available to the widget. Either ensure it's in a shared framework or copy it to the widget target.
-
-- **Timezone Handling:** SunTimes already handles timezone conversion internally. Times are in device local time.
-
-- **Stale Location:** If location is older than 24 hours, no sun times are displayed. This aligns with the widget's daily refresh policy.
-
-- **Privacy:** Widget cannot request location permission—must rely on main app providing location via AppGroupManager.
-
diff --git a/docs/superpowers/specs/2026-03-23-solstice-widget-design.md b/docs/superpowers/specs/2026-03-23-solstice-widget-design.md
deleted file mode 100644
index d91f1f2..0000000
--- a/docs/superpowers/specs/2026-03-23-solstice-widget-design.md
+++ /dev/null
@@ -1,307 +0,0 @@
-# Solstice Countdown Widget & App Design
-
-**Date:** 2026-03-23
-**Project:** Solverv (Solstice Countdown)
-**Status:** Design Approved
-
----
-
-## Overview
-
-Build an iOS app that displays a countdown to the next solstice or equinox, with season-specific imagery and detailed information. The app includes:
-- **Widget Extension:** Compact countdown widget in small, medium, and large sizes
-- **Main App:** Info screen showing expanded details including sunrise/sunset times and upcoming events
-
----
-
-## Requirements
-
-### Functional Requirements
-1. Track all four annual solstices/equinoxes (Spring, Summer, Autumn, Winter)
-2. Display countdown to next event in **days only**
-3. Show season-specific images that change based on upcoming event
-4. Calculate and display sunrise/sunset times for user's current location
-5. Show expanded event details in main app (exact times, season descriptions, upcoming event previews)
-6. Support multiple widget sizes (small, medium, large)
-
-### Non-Functional Requirements
-- All calculations happen locally (no external APIs)
-- Works offline after initial setup
-- Respects location privacy with explicit permission
-- Single location permission prompt (shared via AppGroup)
-- Data synced between app and widget via AppGroup container
-
----
-
-## Architecture
-
-### Data Models
-
-**SolsticeEvent**
-```
-- name: String (e.g., "Summer Solstice", "Spring Equinox")
-- date: Date (UTC)
-- season: Season (enum: spring, summer, autumn, winter)
-- seasonDescription: String (brief description of the season)
-```
-
-**SolsticeData**
-```
-- events: [SolsticeEvent] (hardcoded data, 2025-2030)
-- nextEvent() -> SolsticeEvent (returns next upcoming event)
-- allUpcoming(count: Int) -> [SolsticeEvent] (returns next N events)
-- daysUntil(_ event: SolsticeEvent) -> Int
-- progressToEvent(_ event: SolsticeEvent) -> (elapsed: Int, total: Int)
-```
-
-**SunTimes**
-```
-- latitude: Double
-- longitude: Double
-- date: Date
-- sunrise() -> Date
-- sunset() -> Date
-```
-
-### Data Sharing
-
-**AppGroup Container:** Store user location and cached sunrise/sunset times using the container ID `group.com.ivarlovlie.solverv`
-
-**AppGroup Data Schema:**
-Stored in UserDefaults using the `group.com.ivarlovlie.solverv` container.
-```json
-{
- "userLocation.latitude": Double (e.g., 59.9139),
- "userLocation.longitude": Double (e.g., 10.7522),
- "userLocation.timestamp": String (ISO 8601, e.g., "2026-03-23T10:30:00Z"),
- "userLocation.isDefaultLocation": Boolean (true = Greenwich, false = user-granted location),
-
- "sunTimes.date": String (ISO 8601 date only, e.g., "2026-03-23"),
- "sunTimes.sunrise": String (ISO 8601 datetime in local timezone, e.g., "2026-03-23T06:42:00"),
- "sunTimes.sunset": String (ISO 8601 datetime in local timezone, e.g., "2026-03-23T18:15:00"),
- "sunTimes.timestamp": String (ISO 8601, when calculated, e.g., "2026-03-23T10:30:00Z")
-}
-```
-All Date values stored as ISO 8601 strings for cross-process compatibility.
-
-- **Widget Timeline:** Updates at midnight (local time) using `timelineReloadPolicy: .after(nextMidnight)`
-- Both app and widget read from the same AppGroup container for consistency
-- Cache invalidation: Sunrise/sunset cache is recalculated if stored date differs from today or location changes
-
----
-
-## Widget Specification
-
-### Small Widget (169×169)
-- **Layout:** Vertical stack
- - Seasonal image (fills most of space)
- - Event name (small caption)
- - Countdown in days (large, bold text)
-- **Refresh:** `timelineReloadPolicy: .after(nextMidnight)` where `nextMidnight` is calculated as:
- ```swift
- var calendar = Calendar.current
- var components = calendar.dateComponents([.year, .month, .day], from: Date())
- components.hour = 0
- components.minute = 0
- components.second = 0
- let todayMidnight = calendar.date(from: components)!
- let nextMidnight = calendar.date(byAdding: .day, value: 1, to: todayMidnight)!
- ```
- The widget refreshes daily at midnight local time, automatically accounting for DST transitions.
-- **Purpose:** Quick glance at how many days remain
-
-### Medium Widget (364×169)
-- **Layout:** Horizontal split
- - Left: Seasonal image (square)
- - Right: Vertical stack with event name, countdown (large), progress bar
-- **Refresh:** `timelineReloadPolicy: .after(nextMidnight)` — refreshes at midnight local time to update countdown
-- **Purpose:** Balance of visual and numeric information
-
-### Large Widget (364×364)
-- **Layout:** Vertical stack
- - Top half: Seasonal image
- - Bottom half: Event name, countdown, progress bar, preview of next 3 upcoming events (mini list)
-- **Refresh:** `timelineReloadPolicy: .after(nextMidnight)` — refreshes at midnight local time to update countdown
-- **Purpose:** Comprehensive view with upcoming events preview
-
-### Images
-- One image per season (spring, summer, autumn, winter)
-- Sourced from Assets.xcassets
-- Same image shown for all events in that season
-
-**Image Specifications:**
-- **Asset Names:** `SeasonSpring`, `SeasonSummer`, `SeasonAutumn`, `SeasonWinter` (stored in Assets.xcassets)
-- **Color Set Strategy:** Each season asset has two variants (light and dark mode) using Xcode's Color Set appearance settings
-- **Aspect Ratio:** 1:1 (square)
-- **Resolutions per variant:**
- - 1x: 1024×1024
- - 2x: 2048×2048
- - 3x: 3072×3072
-- **Safe Area:** Ensure important visual content avoids outer 20-point margin (on a 1024×1024 base, keep content within 960×960 center area)
-- **Format:** PNG with alpha channel
-- **Fallback:** If image fails to load, display solid color matching the season (spring: #4CAF50, summer: #FFC107, autumn: #FF9800, winter: #2196F3)
-
----
-
-## Main App Info Screen
-
-### Top Section
-- Seasonal image (landscape orientation friendly)
-- Next event name (large)
-- Countdown in days (very large, prominent)
-- Progress bar showing days elapsed since the previous solstice/equinox divided by total days until next solstice/equinox
- - Calculation: `(today - lastSolsticeDate) / (nextSolsticeDate - lastSolsticeDate)`
- - Example: Winter Solstice was Dec 21, 2025 (passed). Spring Equinox is Mar 20, 2026 (89 days later). On Jan 21, 2026, progress is 31/89 days elapsed.
- - The progress bar **resets to 0%** the moment a new solstice/equinox occurs
-
-### Middle Section
-- **Today's Sun Times**
- - Sunrise time
- - Sunset time
- - Calculated from device location
-- **Season Info**
- - Season name
- - Brief description (e.g., "Spring Equinox — Day and night are approximately equal length")
-
-### Bottom Section
-- **Upcoming Events List** (scrollable)
- - Shows next 8-12 events
- - Each row displays:
- - Event name (e.g., "Spring Equinox")
- - Date/Time in local timezone, 12-hour format with AM/PM (e.g., "Mar 20, 2026 2:46 PM")
- - Days remaining as integer (e.g., "45 days")
- - Season color indicator: 12pt circle matching the season's primary color (spring: green, summer: yellow, autumn: orange, winter: blue)
-
-### Navigation
-- Tab bar or simple navigation to this screen
-- Refresh button to manually update sunrise/sunset (in case location changed)
-
----
-
-## Time Zone & Location Handling
-
-### Location
-- Request permission on first app launch
-- Store latitude/longitude in AppGroup container
-- Fall back to Greenwich/UTC (0°, 0°) if permission denied
-- User can manually update location in app settings
-
-### Time Zones
-- All solstice times stored in UTC (as they are now)
-- Convert to user's local timezone for display
-- Sunrise/sunset calculated for user's timezone and location
-
-### Sunrise/Sunset Algorithm
-- Implement a simplified solar position algorithm based on the NOAA algorithm (https://github.com/NOAA-OWP/sunpy)
-- No external APIs; algorithm is self-contained
-- **Algorithm Reference:** NOAA Solar Position Algorithm
- - Calculate solar declination using Spencer's formula
- - Calculate equation of time for date
- - Calculate hour angle at sunrise/sunset
- - Convert to local solar time then UTC
-- **Inputs:** latitude (Double), longitude (Double), date (Date in user's timezone)
-- **Outputs:** sunrise (Date), sunset (Date) in local timezone
-- **Cache:** Store in AppGroup container; recalculate daily or when location changes
-- **Accuracy:** Results valid to ±2 minutes
-
----
-
-## Error Handling & Edge Cases
-
-### Location Permission
-- App requests permission on first launch
-- If denied, use default location (Greenwich) and notify user
-- User can grant permission later in system Settings
-
-### Time Zone Edge Cases
-- Solstice at midnight: Display correctly in both UTC and local time
-- User crosses timezone: Times update automatically on app launch
-- Widget timezone: Uses device timezone (set by system)
-
-### Data Integrity
-- Solstice dates are hardcoded and immutable
-- Sunrise/sunset cached but recalculated daily
-- Widget syncs with app via AppGroup on launch
-
----
-
-## Testing Strategy
-
-### Widget Testing
-- Preview all three widget sizes with mock solstice data
-- Verify countdown updates correctly across timezone boundaries
-- Test image display in different iOS versions (iOS 17 fallback)
-
-### App Testing
-- Verify sunrise/sunset calculations against known values
-- Test location permission flows (allowed, denied, not yet asked)
-- Test data sync between app and widget
-- Verify time zone conversions for various user locations
-
-### Integration Testing
-- Widget refreshes daily and displays current countdown
-- App and widget show consistent "next event" data
-- Location changes update sunrise/sunset in real time
-
----
-
-## Implementation Notes
-
-### Existing Code
-- `SolvervDef` already contains solstice dates (2025-2030)
-- Widget structure (`Solsnu_Widget.swift`) is scaffolded
-- Main app has basic SwiftUI structure ready for info screen
-
-### New Components to Build
-- `SunTimes` calculation (sunrise/sunset)
-- `SolsticeEvent` model
-- Widget layout variants (small, medium, large)
-- Info screen UI
-- AppGroup data sharing
-
-### Dependencies
-- None (all calculations are built-in or custom)
-- WidgetKit (already available)
-- SwiftUI (already used)
-
----
-
-## Additional Clarifications
-
-### Solstice Date Coverage
-- Hardcoded data spans 2025–2030
-- After 2030, app continues functioning but upcoming events won't display beyond December 2030
-- Plan for data expansion before 2030: extend to 2040+ in an app update
-
-### Season Assignment
-- The displayed season for an event is always the season being celebrated
-- Example: Spring Equinox always displays spring imagery (green), even if shown during late winter on the calendar
-
-### Offline Functionality
-- **First Launch:**
- - App requests location permission
- - If granted: Fetch location and calculate sunrise/sunset for that location, cache in AppGroup container
- - If denied: Use default location (Greenwich, 0°/0°) and cache as `isDefaultLocation: true`
- - All subsequent uses work offline
-- **Subsequent Use:**
- - App and widget work entirely offline; solstice dates and countdown calculations are all local
- - Sunrise/sunset times use cached values from AppGroup container
-- **State Recovery on Permission Changes:**
- - If app is running when user changes location permission in Settings, recalculate sunrise/sunset on next app foreground event
- - If AppGroup container is missing or corrupted on launch, fall back to Greenwich location and log error
-- **Widget Behavior When Location Unavailable:**
- - Widget always displays countdown (never fails)
- - Sunrise/sunset times display as "—" if location permission denied and no cached data exists
-
----
-
-## Success Criteria
-
-✅ Widget displays countdown in days
-✅ Seasonal image changes based on next event
-✅ All four solstices/equinoxes tracked
-✅ Sunrise/sunset times calculated from location
-✅ Info screen shows all requested details
-✅ App and widget data stay in sync
-✅ Works offline after initial setup
-✅ All three widget sizes render correctly
diff --git a/docs/superpowers/specs/2026-03-23-sunrise-sunset-widget-design.md b/docs/superpowers/specs/2026-03-23-sunrise-sunset-widget-design.md
deleted file mode 100644
index 1e4ed54..0000000
--- a/docs/superpowers/specs/2026-03-23-sunrise-sunset-widget-design.md
+++ /dev/null
@@ -1,152 +0,0 @@
-# Sunrise/Sunset Times Widget Feature
-
-**Date:** 2026-03-23
-**Status:** Design Approved
-
-## Overview
-
-Add sunrise and sunset time display to both small and medium widgets. Times are calculated locally using the existing NOAA solar position algorithm, formatted as HH:mm, and updated daily at midnight based on user location.
-
-## Requirements
-
-- Display sunrise/sunset times in HH:mm format on widgets
-- Calculate times locally using existing `SunTimes` utility
-- Use user's current location for calculations
-- Update once daily at midnight (aligned with current refresh policy)
-- Show nothing if location permission is denied or unavailable
-- Apply to both small and medium widget families
-
-## Architecture
-
-### Data Model
-
-Extend `SolvervDef` to include sunrise/sunset times:
-```
-SolvervDef
-├── date: Date
-├── bg: String
-├── sunriseTime: Date? (optional)
-├── sunsetTime: Date? (optional)
-├── sunriseFormatted: String (computed)
-└── sunsetFormatted: String (computed)
-```
-
-**Constructor signature:**
-```swift
-init(date: Date, sunriseTime: Date? = nil, sunsetTime: Date? = nil) {
- self.date = date
- self.sunriseTime = sunriseTime
- self.sunsetTime = sunsetTime
- self.bg = "smallbg"
-}
-```
-
-The formatted properties extract HH:mm in device local time.
-
-### Timeline Provider Logic
-
-Update `Provider.getTimeline()`:
-
-1. Get user's cached location from `AppGroupManager` (widget must use App Group storage, not LocationManager)
-2. Check if location is recent (less than 24 hours old); if stale/unavailable → create entry with nil sunrise/sunset
-3. Initialize local `SunTimes(latitude, longitude, date: currentDate)` calculator
-4. Extract sunrise and sunset times (already converted to device local time)
-5. Create entry with times: `SolvervEntry(def: SolvervDef(date: currentDate, sunriseTime: sr, sunsetTime: ss))`
-6. Return timeline refreshing at next midnight
-
-**Note:** Widget runs in separate process and cannot access main app's LocationManager. Location MUST come from AppGroupManager storage, populated by the main app.
-
-### Data Calculation Flow
-
-```
-Location Permission Check
- ↓ (if granted)
-Get Cached Coordinates
- ↓
-SunTimes Calculator
- ├─ Input: latitude, longitude, date
- ├─ Output: sunrise Date, sunset Date
- └─ Fallback: nil (polar regions, edge cases)
- ↓
-Format as HH:mm
- ↓
-SolvervDef/Entry
- ↓
-Widget Display
-```
-
-### Error Handling
-
-| Scenario | Behavior |
-|----------|----------|
-| Location permission denied | No sunrise/sunset displayed |
-| Location unavailable in AppGroupManager | No sunrise/sunset displayed |
-| Location older than 24 hours | Treat as unavailable; no sunrise/sunset displayed |
-| SunTimes returns nil (polar regions, 24h sun/darkness) | No sunrise/sunset displayed |
-
-### Timezone Handling
-
-- `SunTimes` internally converts to device's local timezone (uses `TimeZone.current`)
-- Sunrise/sunset `Date` objects are in device local time
-- Formatted display uses device local time (HH:mm)
-- **Edge case:** If user changes timezone between midnight and next refresh, sun times remain valid for the previous timezone. They will update correctly at next midnight refresh (when new location may have different timezone). This is acceptable since widget only refreshes daily.
-
-## Implementation Details
-
-### Changes to SolvervDef
-
-Add properties:
-- `sunriseTime: Date?` — raw sunrise time in device local timezone
-- `sunsetTime: Date?` — raw sunset time in device local timezone
-
-Add computed properties for formatted times:
-- `sunriseFormatted: String` — returns "HH:mm" format or empty string if nil
-- `sunsetFormatted: String` — returns "HH:mm" format or empty string if nil
-
-Computed properties use device locale and 24-hour format.
-
-### Changes to Provider
-
-Modify `getTimeline()` to:
-1. Fetch location from `AppGroupManager.getUserLocation()` — widget runs in separate process and must use App Group storage
-2. Check timestamp: if location is older than 24 hours, treat as unavailable
-3. If available, instantiate sunrise/sunset calculator: `SunTimes(latitude: lat, longitude: lon, date: currentDate)` from `/Solverv/Utilities/SunTimes.swift`
-4. Call `calculator.sunrise()` and `calculator.sunset()` to get Date objects
-5. Pass times to `SolvervDef(date: currentDate, sunriseTime: sr, sunsetTime: ss)`
-6. Create entry and return timeline refreshing at next midnight (unchanged)
-
-### Widget View Updates
-
-**SmallWidgetView:**
-- If sunrise/sunset available: display as "↑ HH:mm ↓ HH:mm" or similar compact format
-- If unavailable: display nothing (no placeholder or empty space)
-
-**MediumWidgetView:**
-- If sunrise/sunset available: display sunrise and sunset times in HH:mm format (exact layout TBD based on available space)
-- If unavailable: display nothing (no placeholder or empty space)
-
-Both views:
-- Access times via `entry.def.sunriseFormatted` and `entry.def.sunsetFormatted`
-- Only render if both are non-empty strings
-
-## Timeline & Refresh
-
-- **Frequency:** Once per day at midnight
-- **Rationale:** Sun times change gradually; daily updates are sufficient
-- **Alignment:** Matches existing solstice countdown refresh policy
-
-## Testing
-
-- Test with various locations (tropics, temperate, near poles)
-- Test without location permission
-- Test at date boundaries (midnight refresh)
-- Verify formatted times display correctly
-- Verify nil times result in no display
-
-## Future Considerations
-
-- Visualization of sunrise/sunset (charts, gradients)
-- Daylight duration calculation and trend
-- Multiple locations support
-- Custom location override
-