summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md')
-rw-r--r--docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md1606
1 files changed, 0 insertions, 1606 deletions
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)
-
----