summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-03-23 15:57:49 +0100
committerivar <i@oiee.no>2026-03-23 15:57:49 +0100
commitd3cb239c778f95f39c909f7327d928aae57bccce (patch)
tree91b122b81d619102b9724e1fd0bfe3bd0d166d8b /docs/superpowers/plans
parent592102ef19624cb37004acbc9de258e725cc3ae1 (diff)
downloadsolverv-d3cb239c778f95f39c909f7327d928aae57bccce.tar.xz
solverv-d3cb239c778f95f39c909f7327d928aae57bccce.zip
fix: correct SunTimes algorithm, add TDD tests first, and intermediate build verification
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md270
1 files changed, 188 insertions, 82 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
index e98dc55..78e8b36 100644
--- a/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md
+++ b/docs/superpowers/plans/2026-03-23-solstice-widget-implementation.md
@@ -346,7 +346,76 @@ git commit -m "feat: add SolsticeData manager with hardcoded events 2025-2030"
- Create: `Solverv/Utilities/SunTimes.swift`
- Test: `SolverVTests/Utilities/SunTimesTests.swift`
-- [ ] **Step 1: Write SunTimes class (NOAA algorithm)**
+- [ ] **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
@@ -375,7 +444,7 @@ class SunTimes {
return result.sunset
}
- // NOAA solar position algorithm
+ // 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)
@@ -384,63 +453,87 @@ class SunTimes {
return nil
}
- // Convert date to day of year
- var daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
- if isLeapYear(year) { daysInMonth[1] = 29 }
-
- var dayOfYear = day
- for m in 1..<month {
- dayOfYear += daysInMonth[m - 1]
- }
+ // 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
- // Fractional year in radians
- let gamma = 2.0 * Double.pi * Double(dayOfYear - 1) / Double(isLeapYear(year) ? 366 : 365)
+ // Step 2: Fractional year in radians
+ let daysInYear = isLeapYear(year) ? 366 : 365
+ let gamma = 2.0 * Double.pi * Double(dayOfYear - 1) / Double(daysInYear)
- // Solar declination
+ // 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)
- // Equation of time (minutes)
+ // 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))
- // Hour angle
+ // Step 5: Hour angle at sunrise/sunset
let latRad = latitude * Double.pi / 180.0
let cosH = -tan(latRad) * tan(decl)
- guard cosH >= -1 && cosH <= 1 else {
- // Sun is always up or always down
+ 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
- // Solar noon (local solar time)
- let solarNoon = 12.0 - longitude / 15.0 - eot / 60.0
+ // Step 6: Solar noon in local solar time
+ let solarNoon = 12.0 - (longitude / 15.0) - (eot / 60.0)
- // Sunrise and sunset (local solar time)
- let sunriseTime = solarNoon - h / 15.0
- let sunsetTime = solarNoon + h / 15.0
+ // Step 7: Sunrise and sunset in local solar time
+ let sunriseLST = solarNoon - (h / 15.0)
+ let sunsetLST = solarNoon + (h / 15.0)
- // Convert local solar time to standard time
- let timeZoneOffset = Double(TimeZone.current.secondsFromGMT(for: date)) / 3600.0
- let adjustedSunrise = sunriseTime + timeZoneOffset - longitude / 15.0
- let adjustedSunset = sunsetTime + timeZoneOffset - longitude / 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
- // Create date objects
- var sunriseComponents = calendar.dateComponents([.year, .month, .day], from: date)
- sunriseComponents.hour = Int(adjustedSunrise)
- sunriseComponents.minute = Int((adjustedSunrise.truncatingRemainder(dividingBy: 1)) * 60)
- sunriseComponents.second = 0
+ // Step 9: Create date components, handling day boundary crossing
+ let calendar2 = Calendar.current
+ var baseComponents = calendar2.dateComponents([.year, .month, .day], from: date)
- var sunsetComponents = calendar.dateComponents([.year, .month, .day], from: date)
- sunsetComponents.hour = Int(adjustedSunset)
- sunsetComponents.minute = Int((adjustedSunset.truncatingRemainder(dividingBy: 1)) * 60)
- sunsetComponents.second = 0
+ // 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
+ }
+ }
- guard let sunriseDate = calendar.date(from: sunriseComponents),
- let sunsetDate = calendar.date(from: sunsetComponents) else {
+ 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
}
@@ -453,58 +546,20 @@ class SunTimes {
}
```
-- [ ] **Step 2: Write test for sunrise/sunset calculations**
-
-```swift
-// SolverVTests/Utilities/SunTimesTests.swift
-import XCTest
-@testable import Solverv
-
-final class SunTimesTests: XCTestCase {
- // Test with known location: Oslo (59.9139°N, 10.7522°E)
- // on March 20, 2026 (around spring equinox)
-
- func testSunriseSunsetReturnsValidDates() {
- let dateComponents = DateComponents(year: 2026, month: 3, day: 20)
- let date = Calendar.current.date(from: dateComponents)!
+- [ ] **Step 4: Implement SunTimes (correct algorithm with day boundary handling)**
- let sunTimes = SunTimes(latitude: 59.9139, longitude: 10.7522, date: date)
- let sunrise = sunTimes.sunrise()
- let sunset = sunTimes.sunset()
+Full implementation already shown above with corrected math.
- XCTAssertNotNil(sunrise)
- XCTAssertNotNil(sunset)
- XCTAssertLessThan(sunrise!, sunset!)
- }
+- [ ] **Step 5: Run tests to verify they pass**
- func testSunriseSunsetReasonsableForEquinox() {
- let dateComponents = DateComponents(year: 2026, month: 3, day: 20)
- 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("Should have sunrise/sunset")
- return
- }
+Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test -only SunTimesTests`
+Expected: PASS (3 test cases pass)
- // On equinox, day length should be ~12 hours
- let dayLength = Calendar.current.dateComponents([.minute], from: sunrise, to: sunset).minute ?? 0
- XCTAssertGreaterThan(dayLength, 720 - 60) // Allow ±1 hour variance
- XCTAssertLessThan(dayLength, 720 + 60)
- }
-}
-```
-
-- [ ] **Step 3: Run tests**
-
-Run: `xcodebuild -scheme SolverVTests -destination 'generic/platform=iOS' test`
-Expected: Tests pass
-
-- [ ] **Step 4: Commit**
+- [ ] **Step 6: Commit**
```bash
git add Solverv/Utilities/SunTimes.swift SolverVTests/Utilities/SunTimesTests.swift
-git commit -m "feat: add SunTimes calculator using NOAA algorithm"
+git commit -m "feat: add SunTimes calculator using NOAA algorithm with test-driven approach"
```
---
@@ -666,6 +721,57 @@ 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:**