From 3d40145ffbc6560c2740febd6f07603c72cde0ea Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 13:16:52 -0600 Subject: [PATCH] docs: update planning documents and todos Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 7 +- TO-DOS.md | 52 +- ...1-12-polish-enhancements-implementation.md | 659 +++++++ ...2-progress-tracking-enhancements-design.md | 1519 +++++++++++++++-- .../2026-01-12-todo-bugs-implementation.md | 799 +++++++++ ...26-01-13-in-app-purchase-implementation.md | 225 +-- 6 files changed, 2939 insertions(+), 322 deletions(-) create mode 100644 docs/plans/2026-01-12-polish-enhancements-implementation.md create mode 100644 docs/plans/2026-01-12-todo-bugs-implementation.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3388174..834d727 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,12 @@ "Bash(python -m sportstime_parser scrape:*)", "Bash(pip install:*)", "Bash(python -m pytest:*)", - "Skill(superpowers:brainstorming)" + "Skill(superpowers:brainstorming)", + "Skill(superpowers:subagent-driven-development)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Skill(superpowers:writing-plans)", + "Bash(find:*)" ] } } diff --git a/TO-DOS.md b/TO-DOS.md index 7e91c8a..3412645 100644 --- a/TO-DOS.md +++ b/TO-DOS.md @@ -1,19 +1,37 @@ -1. organize, optimize, dag system with numerous edge cases with tens of thousands of objects. +``` +Read TO-DOS.md in full. + Summarize: + - Goal + - Current Phase + - Active Tasks + Do not write code until this summary is complete. -7. build ios in app purchase for storekit -8. build complete receipt checking system to check if the user has purchased any subscriptions setting a value that can easily be checked anywhere in the app -9. use frontend-design skill to redesign the app +/superpowers:brainstorm todo X <-- will create a design doc in the docs/plan -Issue: Follow team start/end city does not work -Issue: By game gives error date range required but no date range shows -Issue: Date range should always show current selected shit -Issue: By dates with Chicago selected as must stop doesn’t find any game -Issue: I’m not sure scenario is updating when switched -Issue: The follow team start/end should have the same lookup as must see -Issue: Need to add most cities as sort on all trips views -Issue: Must stop needs to be home team. Just did a search with Chicago as must stop and it had Chi as away team -Issue: Coast to coast on home should filter by most stops -Issue: Importing photo from iPhone, taken with iPhone does not import meta data -Issue: No map should be movable on any screen. It should show North America only and should not be movable -Issue: In schedule view today should be highlighted -Issue: Redesign all loading screens in the app - current loading spinner is ugly and feels unpolished \ No newline at end of file +/superpowers:write-plan for the design created + +/superpowers:subagent-driven-development + +read docs/TEST_PLAN.md in full + + Summarize: + - Goal + - Current Phase + - Active Tasks + Do not write code until this summary is complete. +``` + +question: do we need sync schedules anymore in settings + +// things that are new +need to plan: build ios in app purchase for storekit +need to plan: build complete receipt checking system to check if the user has purchased any subscriptions setting a value that can easily be checked anywhere in the app +need to plan: use frontend-design skill to redesign the app + +// new ish to existing features +feature: add the ability to add sports from cloudKit. name, icon, stadiums, etc + +// bugs +Issue: sharing looks really dumb. need to be able to share achievements, league progress, and a trip +Issue: fucking game show at 7 am ... the fuck? +Issue: all all trips view when choosing "packed" "moderate" "relaxed" the capsule the option is in does a weird animation that looks off. diff --git a/docs/plans/2026-01-12-polish-enhancements-implementation.md b/docs/plans/2026-01-12-polish-enhancements-implementation.md new file mode 100644 index 0000000..3b8e8c6 --- /dev/null +++ b/docs/plans/2026-01-12-polish-enhancements-implementation.md @@ -0,0 +1,659 @@ +# Polish Enhancements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add grouped sorting to trip options and expand planning tips to 100+. + +**Architecture:** Two independent features: (1) PlanningTips data file with static array and random selection, wired into HomeView; (2) TripOptionsView enhancement to group results by count/range based on sort option. + +**Tech Stack:** SwiftUI, Swift + +--- + +## Task 1: Create PlanningTips Data Model + +**Files:** +- Create: `SportsTime/Core/Data/PlanningTips.swift` +- Test: `SportsTimeTests/PlanningTipsTests.swift` + +**Step 1: Write the failing test for random tip selection** + +Create test file: + +```swift +// +// PlanningTipsTests.swift +// SportsTimeTests +// + +import Testing +@testable import SportsTime + +struct PlanningTipsTests { + + @Test func allTipsHasAtLeast100Tips() { + #expect(PlanningTips.all.count >= 100) + } + + @Test func randomReturnsRequestedCount() { + let tips = PlanningTips.random(3) + #expect(tips.count == 3) + } + + @Test func randomReturnsUniqueIds() { + let tips = PlanningTips.random(5) + let uniqueIds = Set(tips.map { $0.id }) + #expect(uniqueIds.count == 5) + } + + @Test func eachTipHasNonEmptyFields() { + for tip in PlanningTips.all { + #expect(!tip.icon.isEmpty, "Tip should have icon") + #expect(!tip.title.isEmpty, "Tip should have title") + #expect(!tip.subtitle.isEmpty, "Tip should have subtitle") + } + } + + @Test func randomWithCountGreaterThanAvailableReturnsAll() { + let tips = PlanningTips.random(1000) + #expect(tips.count == PlanningTips.all.count) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/PlanningTipsTests test` + +Expected: FAIL with "Cannot find 'PlanningTips' in scope" + +**Step 3: Write the PlanningTips implementation** + +Create `SportsTime/Core/Data/PlanningTips.swift`: + +```swift +// +// PlanningTips.swift +// SportsTime +// + +import Foundation + +struct PlanningTip: Identifiable { + let id = UUID() + let icon: String + let title: String + let subtitle: String +} + +enum PlanningTips { + static let all: [PlanningTip] = [ + // MARK: - Schedule Timing (~15 tips) + PlanningTip(icon: "calendar.badge.clock", title: "Check schedules early", subtitle: "Game times can change, sync often"), + PlanningTip(icon: "clock.badge.exclamationmark", title: "Watch for doubleheaders", subtitle: "Two games, one day—great value!"), + PlanningTip(icon: "calendar.badge.plus", title: "Add buffer days", subtitle: "Weather delays happen, plan ahead"), + PlanningTip(icon: "clock.arrow.circlepath", title: "Account for time zones", subtitle: "West coast games start late back east"), + PlanningTip(icon: "tv", title: "Check TV schedules", subtitle: "Nationally broadcast games may shift times"), + PlanningTip(icon: "cloud.rain", title: "Rain delay ready", subtitle: "Baseball can push back your whole evening"), + PlanningTip(icon: "calendar", title: "Avoid holiday weekends", subtitle: "Hotels and tickets spike on long weekends"), + PlanningTip(icon: "sunrise", title: "Day games rock", subtitle: "More time to explore the city after"), + PlanningTip(icon: "moon.stars", title: "Night games = city vibes", subtitle: "Experience the stadium lights atmosphere"), + PlanningTip(icon: "sportscourt", title: "Weekend series strategy", subtitle: "Catch Friday-Saturday-Sunday for max games"), + PlanningTip(icon: "calendar.badge.minus", title: "Check for off-days", subtitle: "Teams travel on Mondays and Thursdays"), + PlanningTip(icon: "exclamationmark.triangle", title: "Spring training caution", subtitle: "Split-squad games mean fewer stars"), + PlanningTip(icon: "clock", title: "First pitch timing", subtitle: "Arrive 60-90 minutes early for batting practice"), + PlanningTip(icon: "hourglass", title: "Game length varies", subtitle: "Baseball: 3hrs, Basketball: 2.5hrs, Hockey: 2.5hrs"), + PlanningTip(icon: "arrow.clockwise", title: "Reschedule alerts", subtitle: "Enable notifications for schedule changes"), + + // MARK: - Driving/Route Tips (~15 tips) + PlanningTip(icon: "car.fill", title: "Plan rest days", subtitle: "Don't overdo the driving"), + PlanningTip(icon: "fuelpump.fill", title: "Map gas stations", subtitle: "Some stretches are remote"), + PlanningTip(icon: "road.lanes", title: "Avoid rush hour", subtitle: "Leave mid-morning or after 7pm"), + PlanningTip(icon: "map", title: "Scenic route option", subtitle: "Sometimes the long way is the fun way"), + PlanningTip(icon: "bolt.car", title: "EV charging stops", subtitle: "Plan charging during meals for efficiency"), + PlanningTip(icon: "car.side", title: "4-hour driving max", subtitle: "More than 4 hours makes for tired fans"), + PlanningTip(icon: "location.fill", title: "Download offline maps", subtitle: "Cell service can be spotty in rural areas"), + PlanningTip(icon: "gauge.medium", title: "Check tire pressure", subtitle: "Long trips need proper inflation"), + PlanningTip(icon: "wrench.fill", title: "Pre-trip car check", subtitle: "Oil, fluids, wipers—be road ready"), + PlanningTip(icon: "road.lanes.curved.right", title: "Interstate vs scenic", subtitle: "Interstates are faster but highways are prettier"), + PlanningTip(icon: "figure.wave", title: "Stretch breaks", subtitle: "Stop every 2 hours to stay sharp"), + PlanningTip(icon: "cup.and.saucer.fill", title: "Coffee shop stops", subtitle: "Local cafes beat drive-thru every time"), + PlanningTip(icon: "music.note", title: "Curate your playlist", subtitle: "Great music makes miles fly by"), + PlanningTip(icon: "headphones", title: "Podcast the drive", subtitle: "Sports podcasts to get hyped"), + PlanningTip(icon: "car.2.fill", title: "Carpool when possible", subtitle: "Split costs and share the driving"), + + // MARK: - Stadium-Specific (~15 tips) + PlanningTip(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included"), + PlanningTip(icon: "fork.knife", title: "Research stadium food", subtitle: "Some parks have legendary eats"), + PlanningTip(icon: "camera.fill", title: "Photo opportunities", subtitle: "Scout iconic spots before game day"), + PlanningTip(icon: "building.2", title: "Stadium tours", subtitle: "Many offer behind-the-scenes access"), + PlanningTip(icon: "figure.walk", title: "Walk the concourse", subtitle: "Each stadium has hidden gems"), + PlanningTip(icon: "bag.fill", title: "Clear bag policy", subtitle: "Most stadiums require clear bags now"), + PlanningTip(icon: "iphone", title: "Mobile tickets ready", subtitle: "Screenshot or download for offline access"), + PlanningTip(icon: "chair.lounge.fill", title: "Seat selection matters", subtitle: "Research views before buying tickets"), + PlanningTip(icon: "sun.max.fill", title: "Shade vs sun seats", subtitle: "Day games: pay extra for shade"), + PlanningTip(icon: "hand.raised.fill", title: "Foul ball territory", subtitle: "Bring a glove to baseline sections"), + PlanningTip(icon: "tshirt.fill", title: "Wear neutral colors", subtitle: "Or proudly rep the home team!"), + PlanningTip(icon: "sparkles", title: "Retro stadium charm", subtitle: "Fenway, Wrigley—experience history"), + PlanningTip(icon: "building.columns", title: "New stadium tech", subtitle: "Modern parks have amazing amenities"), + PlanningTip(icon: "trophy.fill", title: "Championship banners", subtitle: "Look up—history hangs from the rafters"), + PlanningTip(icon: "person.3.fill", title: "Fan sections rock", subtitle: "The bleachers have the best energy"), + + // MARK: - Budget Tips (~15 tips) + PlanningTip(icon: "dollarsign.circle", title: "Book hotels early", subtitle: "Game days fill up fast"), + PlanningTip(icon: "ticket.fill", title: "Weekday games cheaper", subtitle: "Tuesday-Thursday = better deals"), + PlanningTip(icon: "creditcard.fill", title: "Set a daily budget", subtitle: "Stadium food adds up quick"), + PlanningTip(icon: "house.fill", title: "Stay outside downtown", subtitle: "Uber in, save on hotels"), + PlanningTip(icon: "bed.double.fill", title: "Points and rewards", subtitle: "Hotel loyalty programs pay off on road trips"), + PlanningTip(icon: "airplane", title: "Compare fly vs drive", subtitle: "Sometimes flying is cheaper than gas"), + PlanningTip(icon: "cart.fill", title: "Grocery runs help", subtitle: "Stock up on snacks, skip concessions"), + PlanningTip(icon: "parkingsign.circle", title: "Pre-book parking", subtitle: "SpotHero and ParkWhiz save money"), + PlanningTip(icon: "percent", title: "Group discounts", subtitle: "4+ people often get bundle deals"), + PlanningTip(icon: "giftcard.fill", title: "Gift card strategy", subtitle: "Buy discounted cards for stadium spending"), + PlanningTip(icon: "arrow.down.circle", title: "Last-minute tickets", subtitle: "Prices drop close to game time"), + PlanningTip(icon: "arrow.up.circle", title: "Book rivalry games early", subtitle: "Yankees-Red Sox sells out fast"), + PlanningTip(icon: "wallet.pass.fill", title: "Season ticket resale", subtitle: "STH often sell below face value"), + PlanningTip(icon: "banknote.fill", title: "Cash for parking", subtitle: "Some lots still don't take cards"), + PlanningTip(icon: "tag.fill", title: "Student/military discounts", subtitle: "Always ask—many teams offer them"), + + // MARK: - Multi-Sport Strategies (~15 tips) + PlanningTip(icon: "sportscourt.fill", title: "Mix sports", subtitle: "NBA + NHL share many cities"), + PlanningTip(icon: "calendar.badge.clock", title: "Season overlap sweet spot", subtitle: "April: MLB + NBA + NHL all active"), + PlanningTip(icon: "building.2.fill", title: "Shared arenas", subtitle: "Same building, different sports—efficient!"), + PlanningTip(icon: "figure.basketball", title: "Basketball + hockey combo", subtitle: "Many cities host both in one arena"), + PlanningTip(icon: "baseball.fill", title: "Baseball road trip season", subtitle: "Summer = the classic sports adventure"), + PlanningTip(icon: "hockey.puck.fill", title: "Hockey playoff intensity", subtitle: "April-June games are electric"), + PlanningTip(icon: "basketball.fill", title: "NBA in March", subtitle: "Playoff push means high-stakes games"), + PlanningTip(icon: "leaf.fill", title: "Fall football overlap", subtitle: "NFL Sundays + college Saturdays"), + PlanningTip(icon: "snowflake", title: "Winter sports combo", subtitle: "NBA + NHL peak season in January"), + PlanningTip(icon: "sun.min.fill", title: "Spring training + spring break", subtitle: "Arizona and Florida in March"), + PlanningTip(icon: "figure.run", title: "Cross-sport rivalries", subtitle: "Same city teams often play same weekends"), + PlanningTip(icon: "arrow.triangle.branch", title: "Hub city strategy", subtitle: "Chicago, LA, NYC have multiple teams"), + PlanningTip(icon: "globe.americas.fill", title: "Regional concentration", subtitle: "Northeast has tons of teams close together"), + PlanningTip(icon: "flag.fill", title: "Border city bonus", subtitle: "Toronto + Buffalo = international trip!"), + PlanningTip(icon: "star.circle.fill", title: "All-Star breaks", subtitle: "Avoid—no regular games during break"), + + // MARK: - Regional Tips (~15 tips) + PlanningTip(icon: "cloud.sun.fill", title: "Check the weather", subtitle: "April baseball can be chilly"), + PlanningTip(icon: "thermometer.sun.fill", title: "Summer heat warning", subtitle: "Phoenix and Texas get brutal in July"), + PlanningTip(icon: "snow", title: "Winter driving caution", subtitle: "Great Lakes region gets snow early"), + PlanningTip(icon: "wind", title: "Chicago wind factor", subtitle: "Wrigley wind changes the game"), + PlanningTip(icon: "humidity.fill", title: "Southern humidity", subtitle: "Atlanta and Houston: bring water"), + PlanningTip(icon: "mountain.2.fill", title: "Denver altitude", subtitle: "Drink extra water at Coors Field"), + PlanningTip(icon: "beach.umbrella.fill", title: "California weather wins", subtitle: "LA and San Diego = perfect game days"), + PlanningTip(icon: "building.fill", title: "NYC transit tips", subtitle: "Subway to the stadium, skip driving"), + PlanningTip(icon: "tram.fill", title: "Boston T access", subtitle: "Green Line to Fenway, easy as pie"), + PlanningTip(icon: "car.ferry.fill", title: "Seattle ferry option", subtitle: "Combine a game with Puget Sound views"), + PlanningTip(icon: "leaf.arrow.circlepath", title: "Fall foliage bonus", subtitle: "Northeast in October = stunning drives"), + PlanningTip(icon: "theatermasks.fill", title: "Local events check", subtitle: "Festivals can spike hotel prices"), + PlanningTip(icon: "music.mic", title: "Concert conflicts", subtitle: "Arena shows can affect parking"), + PlanningTip(icon: "graduationcap.fill", title: "College town energy", subtitle: "Ann Arbor, Madison—fun atmospheres"), + PlanningTip(icon: "flag.checkered", title: "Racing conflicts", subtitle: "Indy 500 time? Indianapolis books up"), + + // MARK: - Game Day Tips (~15 tips) + PlanningTip(icon: "figure.walk", title: "Arrive early", subtitle: "Explore the stadium before first pitch"), + PlanningTip(icon: "drop.fill", title: "Stay hydrated", subtitle: "Especially at outdoor summer games"), + PlanningTip(icon: "sun.max.trianglebadge.exclamationmark", title: "Sunscreen essential", subtitle: "Day games can burn—protect yourself"), + PlanningTip(icon: "battery.100", title: "Charge your phone", subtitle: "You'll want photos and videos"), + PlanningTip(icon: "powerplug.fill", title: "Bring a battery pack", subtitle: "Mobile tickets need power"), + PlanningTip(icon: "speaker.wave.3.fill", title: "Download team app", subtitle: "In-seat ordering, replays, and more"), + PlanningTip(icon: "menucard.fill", title: "Check signature dishes", subtitle: "Every stadium has a must-try item"), + PlanningTip(icon: "clock.badge.checkmark", title: "Gates open timing", subtitle: "Usually 90 minutes before first pitch"), + PlanningTip(icon: "figure.mixed.cardio", title: "Pre-game neighborhood walk", subtitle: "See the local scene before heading in"), + PlanningTip(icon: "wineglass.fill", title: "Pre-game spots", subtitle: "Research nearby bars and restaurants"), + PlanningTip(icon: "gift.fill", title: "Bobblehead nights", subtitle: "Arrive extra early for giveaways"), + PlanningTip(icon: "pencil.and.scribble", title: "Autograph opportunities", subtitle: "Get there for batting practice"), + PlanningTip(icon: "person.badge.plus", title: "Meet other fans", subtitle: "Road trip fans bond fast"), + PlanningTip(icon: "heart.fill", title: "Soak it in", subtitle: "Put the phone down sometimes"), + PlanningTip(icon: "memoryphoto", title: "Take scorecard notes", subtitle: "Physical mementos beat digital"), + ] + + static func random(_ count: Int = 3) -> [PlanningTip] { + Array(all.shuffled().prefix(count)) + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/PlanningTipsTests test` + +Expected: All 5 tests PASS + +**Step 5: Commit** + +```bash +git add SportsTime/Core/Data/PlanningTips.swift SportsTimeTests/PlanningTipsTests.swift +git commit -m "$(cat <<'EOF' +feat: add PlanningTips data with 100+ tips + +Adds PlanningTip struct and PlanningTips enum with static array +of 105 tips across 7 categories (schedule timing, driving, stadium, +budget, multi-sport, regional, game day). Includes random() function +for selecting N unique tips. + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 2: Wire PlanningTips into HomeView + +**Files:** +- Modify: `SportsTime/Features/Home/Views/HomeView.swift:311-332` + +**Step 1: Add state property and update tipsSection** + +In `HomeView.swift`, add a new `@State` property after line 18 (after `selectedSuggestedTrip`): + +```swift +@State private var displayedTips: [PlanningTip] = [] +``` + +**Step 2: Replace the hardcoded tipsSection** + +Replace lines 313-332 (the entire `tipsSection` computed property) with: + +```swift +private var tipsSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("Planning Tips") + .font(.title2) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + VStack(spacing: Theme.Spacing.xs) { + ForEach(displayedTips) { tip in + TipRow(icon: tip.icon, title: tip.title, subtitle: tip.subtitle) + } + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + .onAppear { + if displayedTips.isEmpty { + displayedTips = PlanningTips.random(3) + } + } +} +``` + +**Step 3: Run the app to verify tips load** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` + +Expected: Build succeeds. Launch app and verify 3 random tips appear in the Planning Tips section. + +**Step 4: Commit** + +```bash +git add SportsTime/Features/Home/Views/HomeView.swift +git commit -m "$(cat <<'EOF' +feat: wire PlanningTips into HomeView + +- Add displayedTips @State to HomeView +- Update tipsSection to use PlanningTips.random(3) +- Tips load on first appear, persist during session +- Tips refresh only on app relaunch + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 3: Add Grouped Sorting to TripOptionsView + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripCreationView.swift:1570-1839` +- Test: `SportsTimeTests/TripOptionsGroupingTests.swift` + +**Step 1: Write failing test for grouping logic** + +Create test file: + +```swift +// +// TripOptionsGroupingTests.swift +// SportsTimeTests +// + +import Testing +@testable import SportsTime + +struct TripOptionsGroupingTests { + + // Helper to create mock ItineraryOption + private func makeOption(stops: [(city: String, games: [String])], totalMiles: Double = 500) -> ItineraryOption { + let tripStops = stops.map { stopData in + TripStop( + city: stopData.city, + state: "XX", + coordinate: .init(latitude: 0, longitude: 0), + games: stopData.games, + arrivalDate: Date(), + departureDate: Date(), + travelFromPrevious: nil + ) + } + return ItineraryOption( + id: UUID().uuidString, + stops: tripStops, + totalDistanceMiles: totalMiles, + totalDrivingHours: totalMiles / 60, + score: 1.0 + ) + } + + @Test func groupByCityCountDescending() { + let options = [ + makeOption(stops: [("NYC", []), ("Boston", [])]), // 2 cities + makeOption(stops: [("LA", []), ("SF", []), ("Seattle", [])]), // 3 cities + makeOption(stops: [("Chicago", [])]), // 1 city + ] + + let grouped = TripOptionsGrouper.groupByCityCount(options, ascending: false) + + #expect(grouped.count == 3) + #expect(grouped[0].header == "3 cities") + #expect(grouped[1].header == "2 cities") + #expect(grouped[2].header == "1 city") + } + + @Test func groupByGameCountAscending() { + let options = [ + makeOption(stops: [("NYC", ["g1", "g2", "g3"])]), // 3 games + makeOption(stops: [("LA", ["g1"])]), // 1 game + makeOption(stops: [("Chicago", ["g1", "g2"])]), // 2 games + ] + + let grouped = TripOptionsGrouper.groupByGameCount(options, ascending: true) + + #expect(grouped.count == 3) + #expect(grouped[0].header == "1 game") + #expect(grouped[1].header == "2 games") + #expect(grouped[2].header == "3 games") + } + + @Test func groupByMileageRangeDescending() { + let options = [ + makeOption(stops: [("NYC", [])], totalMiles: 300), // 0-500 + makeOption(stops: [("LA", [])], totalMiles: 1200), // 1000-1500 + makeOption(stops: [("Chicago", [])], totalMiles: 2500), // 2000+ + ] + + let grouped = TripOptionsGrouper.groupByMileageRange(options, ascending: false) + + #expect(grouped[0].header == "2000+ mi") + #expect(grouped[1].header == "1000-1500 mi") + #expect(grouped[2].header == "0-500 mi") + } + + @Test func groupByMileageRangeAscending() { + let options = [ + makeOption(stops: [("NYC", [])], totalMiles: 300), + makeOption(stops: [("LA", [])], totalMiles: 1200), + makeOption(stops: [("Chicago", [])], totalMiles: 2500), + ] + + let grouped = TripOptionsGrouper.groupByMileageRange(options, ascending: true) + + #expect(grouped[0].header == "0-500 mi") + #expect(grouped[1].header == "1000-1500 mi") + #expect(grouped[2].header == "2000+ mi") + } + + @Test func emptyOptionsReturnsEmptyGroups() { + let grouped = TripOptionsGrouper.groupByCityCount([], ascending: false) + #expect(grouped.isEmpty) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripOptionsGroupingTests test` + +Expected: FAIL with "Cannot find 'TripOptionsGrouper' in scope" + +**Step 3: Add TripOptionsGrouper utility** + +Add this before the `TripOptionsView` struct (around line 1570) in `TripCreationView.swift`: + +```swift +// MARK: - Trip Options Grouper + +enum TripOptionsGrouper { + typealias GroupedOptions = (header: String, options: [ItineraryOption]) + + static func groupByCityCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { + let grouped = Dictionary(grouping: options) { option in + Set(option.stops.map { $0.city }).count + } + let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key } + return sorted.map { count, opts in + ("\(count) \(count == 1 ? "city" : "cities")", opts) + } + } + + static func groupByGameCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { + let grouped = Dictionary(grouping: options) { $0.totalGames } + let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key } + return sorted.map { count, opts in + ("\(count) \(count == 1 ? "game" : "games")", opts) + } + } + + static func groupByMileageRange(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { + let ranges: [(min: Int, max: Int, label: String)] = [ + (0, 500, "0-500 mi"), + (500, 1000, "500-1000 mi"), + (1000, 1500, "1000-1500 mi"), + (1500, 2000, "1500-2000 mi"), + (2000, Int.max, "2000+ mi") + ] + + var groupedDict: [String: [ItineraryOption]] = [:] + for option in options { + let miles = Int(option.totalDistanceMiles) + for range in ranges { + if miles >= range.min && miles < range.max { + groupedDict[range.label, default: []].append(option) + break + } + } + } + + // Sort by range order + let rangeOrder = ascending ? ranges : ranges.reversed() + return rangeOrder.compactMap { range in + guard let opts = groupedDict[range.label], !opts.isEmpty else { return nil } + return (range.label, opts) + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripOptionsGroupingTests test` + +Expected: All 5 tests PASS + +**Step 5: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripCreationView.swift SportsTimeTests/TripOptionsGroupingTests.swift +git commit -m "$(cat <<'EOF' +feat: add TripOptionsGrouper utility for grouped sorting + +Adds grouping functions for city count, game count, and mileage range +with ascending/descending support. Used by TripOptionsView for +sectioned display based on sort option. + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 4: Integrate Grouped Sections into TripOptionsView UI + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripCreationView.swift:1570-1839` (TripOptionsView) + +**Step 1: Add groupedOptions computed property to TripOptionsView** + +Add this computed property after `filteredAndSortedOptions` (around line 1635): + +```swift +private var groupedOptions: [TripOptionsGrouper.GroupedOptions] { + switch sortOption { + case .recommended, .bestEfficiency: + // Flat list, no grouping + return [("", filteredAndSortedOptions)] + + case .mostCities: + return TripOptionsGrouper.groupByCityCount(filteredAndSortedOptions, ascending: false) + + case .mostGames: + return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: false) + + case .leastGames: + return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: true) + + case .mostMiles: + return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: false) + + case .leastMiles: + return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: true) + } +} +``` + +**Step 2: Replace the options list with sectioned display** + +In `TripOptionsView.body`, replace the options list section (the ForEach for `filteredAndSortedOptions` around lines 1668-1679) with: + +```swift +// Options list (grouped when applicable) +if filteredAndSortedOptions.isEmpty { + emptyFilterState + .padding(.top, Theme.Spacing.xl) +} else { + ForEach(Array(groupedOptions.enumerated()), id: \.offset) { _, group in + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Section header (only if non-empty) + if !group.header.isEmpty { + HStack { + Text(group.header) + .font(.headline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Text("\(group.options.count)") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(.horizontal, Theme.Spacing.md) + .padding(.top, Theme.Spacing.md) + } + + // Options in this group + ForEach(group.options) { option in + TripOptionCard( + option: option, + games: games, + onSelect: { + selectedTrip = convertToTrip(option) + showTripDetail = true + } + ) + .padding(.horizontal, Theme.Spacing.md) + } + } + } +} +``` + +**Step 3: Build and verify UI** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` + +Expected: Build succeeds. Run app, plan a trip, verify grouped sections appear when sorting by Most Cities, Most Games, etc. + +**Step 4: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripCreationView.swift +git commit -m "$(cat <<'EOF' +feat: add grouped sections to TripOptionsView + +- Add groupedOptions computed property using TripOptionsGrouper +- Update UI to show section headers with counts when grouping +- Recommended and Best Efficiency remain flat lists +- Empty sections are automatically hidden + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 5: Run Full Test Suite and Final Verification + +**Files:** +- None (verification only) + +**Step 1: Run full test suite** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test` + +Expected: All tests pass (including new PlanningTipsTests and TripOptionsGroupingTests) + +**Step 2: Verify features manually** + +1. Launch app +2. Check Home tab → Planning Tips shows 3 random tips +3. Kill app, relaunch → verify tips changed +4. Plan a trip with multiple options +5. On TripOptionsView: + - Select "Most Cities" → verify grouped by city count descending + - Select "Least Games" → verify grouped by game count ascending + - Select "Most Miles" → verify grouped by mileage ranges + - Select "Recommended" → verify flat list (no headers) + +**Step 3: Final commit with all changes** + +```bash +git status +# Verify all changes are committed +``` + +Expected: Working tree clean, all features implemented and tested. + +--- + +## Summary + +| Task | Description | Tests | +|------|-------------|-------| +| 1 | Create PlanningTips data model with 100+ tips | 5 tests | +| 2 | Wire PlanningTips into HomeView | Manual verification | +| 3 | Add TripOptionsGrouper utility | 5 tests | +| 4 | Integrate grouped sections into TripOptionsView UI | Manual verification | +| 5 | Full test suite run and verification | All tests pass | + +**Total new tests:** 10 +**Files created:** 2 (`PlanningTips.swift`, `PlanningTipsTests.swift`, `TripOptionsGroupingTests.swift`) +**Files modified:** 2 (`HomeView.swift`, `TripCreationView.swift`) diff --git a/docs/plans/2026-01-12-progress-tracking-enhancements-design.md b/docs/plans/2026-01-12-progress-tracking-enhancements-design.md index 4579fcb..4b5dc95 100644 --- a/docs/plans/2026-01-12-progress-tracking-enhancements-design.md +++ b/docs/plans/2026-01-12-progress-tracking-enhancements-design.md @@ -1,133 +1,1424 @@ -# Progress Tracking Enhancements Design +# Progress Tracking Enhancements Implementation Plan -**Date:** 2026-01-12 -**Status:** Draft -**Scope:** High-level overview for scoping/prioritization +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -## Goal +**Goal:** Enhance the stadium bucket list experience with multiple visits per stadium display, a dedicated games history view, and an interactive zoomable map. -Enhance the stadium bucket list experience with richer visit tracking, a dedicated games history view, and an interactive map. +**Architecture:** Extends the existing Progress feature module with new views (GamesHistoryView) and modifications to existing views (ProgressMapView, VisitDetailView, StadiumVisitSheet). Uses existing SwiftData models (StadiumVisit already supports multiple visits). Adds computed properties to ProgressViewModel for grouping data. -## Features +**Tech Stack:** SwiftUI, SwiftData, MapKit -1. **Multiple visits per stadium** - Track and display each visit separately -2. **View all games attended** - New screen with chronological list, filterable by year/sport -3. **Zoomable map** - Enable pinch-to-zoom and pan on the progress map +--- -## Current State +## Phase 1: Zoomable Map -- `StadiumVisit` model already supports multiple visits per stadium (data is there) -- UI shows most recent visit, doesn't surface visit history well -- `ProgressMapView` has interactions disabled (`interactionModes: []`) -- No dedicated "all games attended" view exists +### Task 1: Create ProgressMapView Interaction Tests -## Feature Details +**Files:** +- Create: `SportsTimeTests/Features/Progress/ProgressMapViewTests.swift` -### 1. Multiple Visits Per Stadium +**Step 1: Write the test file** -The `StadiumVisit` model already stores multiple visits. UI changes needed: - -| Component | Change | -|-----------|--------| -| `VisitDetailView` | Show list of all visits for that stadium, not just most recent | -| `StadiumVisitSheet` | "Add Another Visit" button when stadium already has visits | -| Stadium cards | Show visit count badge ("3 visits") | - -**UI Flow:** -- Tapping a visited stadium shows a list of visit cards -- Each visit card is expandable to show game details, photos, notes -- "Add Visit" button at bottom of visit list - -### 2. View All Games Attended (GamesHistoryView) - -New screen accessible from Progress tab: - -**Header:** "X Games Attended" with sport filter chips - -**Layout:** -- Grouped by year (2026, 2025, 2024...) with sticky section headers -- Each row: Date, teams (vs format), stadium, score, sport icon -- Tapping a row opens the visit detail - -**Filters:** -- Sport: Multi-select chips at top (MLB, NBA, NHL, NFL) -- Year: Scroll-based (no separate picker needed) - -**Empty State:** "No games recorded yet. Add your first visit!" - -**Access Points:** -- Button in Progress tab header -- "See All" link in recent visits section - -### 3. Zoomable Map - -| Current | New | -|---------|-----| -| `interactionModes: []` | `interactionModes: [.zoom, .pan]` | -| Fixed US region | Remembers last position | -| Tap shows name label only | Tap zooms to stadium + shows detail card | - -**Additional UI:** -- "Reset View" floating button (bottom-right corner) -- Appears after user pans or zooms away from default view -- Tapping resets to full continental US view - -**Zoom-to-Stadium Behavior:** -- Tapping a pin animates map to that stadium -- Uses ~0.01 lat/lon span (city level zoom) -- Shows stadium detail card below map - -## File Structure - -``` -Features/Progress/Views/ -├── ProgressMapView.swift # MODIFY - enable interactions, add reset button -├── VisitDetailView.swift # MODIFY - show all visits as list -├── StadiumVisitSheet.swift # MODIFY - "Add Another Visit" flow -├── GamesHistoryView.swift # NEW - all games attended screen -├── GamesHistoryRow.swift # NEW - single game row component -└── VisitListCard.swift # NEW - compact visit card for lists -``` - -## Key Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Games history access | Header button + "See All" link | Multiple entry points for discoverability | -| Year grouping | Sticky section headers | Standard iOS pattern, easy to scan | -| Map zoom on tap | Animate to 0.01 span | City-level detail without losing context | -| Reset button visibility | Show only after pan/zoom | Don't clutter UI when at default view | -| Visit count badge | Orange circle with number | Consistent with app theme | -| Default sort | Most recent first | Users care about recent visits | - -## Data Changes - -No model changes needed: -- `StadiumVisit` already supports multiple visits per stadium -- `ProgressViewModel` already has `visits` array - -Add computed property to `ProgressViewModel`: ```swift -var allVisitsByYear: [Int: [VisitSummary]] { - Dictionary(grouping: allVisits) { Calendar.current.component(.year, from: $0.visitDate) } +import XCTest +import MapKit +@testable import SportsTime + +final class ProgressMapViewTests: XCTestCase { + + func test_MapViewModel_TracksUserInteraction() { + // Given: A map view model + let viewModel = MapInteractionViewModel() + + // When: User interacts with map (zoom/pan) + viewModel.userDidInteract() + + // Then: Interaction is tracked + XCTAssertTrue(viewModel.hasUserInteracted, "Should track user interaction") + XCTAssertTrue(viewModel.shouldShowResetButton, "Should show reset button after interaction") + } + + func test_MapViewModel_ResetClearsInteraction() { + // Given: A map with user interaction + let viewModel = MapInteractionViewModel() + viewModel.userDidInteract() + + // When: User resets the view + viewModel.resetToDefault() + + // Then: Interaction flag is cleared + XCTAssertFalse(viewModel.hasUserInteracted, "Should clear interaction flag after reset") + XCTAssertFalse(viewModel.shouldShowResetButton, "Should hide reset button after reset") + } + + func test_MapViewModel_ZoomToStadium_SetsCorrectRegion() { + // Given: A map view model + let viewModel = MapInteractionViewModel() + + // When: Zooming to a stadium location + let stadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262) // Yankee Stadium + viewModel.zoomToStadium(at: stadiumCoord) + + // Then: Region is set to city-level zoom + XCTAssertEqual(viewModel.region.center.latitude, stadiumCoord.latitude, accuracy: 0.001) + XCTAssertEqual(viewModel.region.center.longitude, stadiumCoord.longitude, accuracy: 0.001) + XCTAssertEqual(viewModel.region.span.latitudeDelta, 0.01, accuracy: 0.005, "Should use city-level zoom span") + } } ``` -## Not Included (YAGNI) +**Step 2: Run test to verify it fails** -- Export games history to CSV -- Share individual visit cards to social media -- Map clustering for dense stadium areas (revisit if performance issues) -- Search within games history (filter chips should suffice) +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ProgressMapViewTests test 2>&1 | tail -20` -## Dependencies +Expected: FAIL with "Cannot find 'MapInteractionViewModel' in scope" -- None - uses existing models and data providers +**Step 3: Commit test file** -## Testing Considerations +```bash +git add SportsTimeTests/Features/Progress/ProgressMapViewTests.swift +git commit -m "test: add ProgressMapView interaction tests" +``` -- Test multiple visits for same stadium (add, view, edit) -- Test games history with various filter combinations -- Test map zoom/pan performance with all stadiums loaded -- Test reset button appears/disappears correctly -- Test empty states for new users +--- + +### Task 2: Create MapInteractionViewModel + +**Files:** +- Create: `SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift` + +**Step 1: Create the view model** + +```swift +import SwiftUI +import MapKit + +@MainActor +@Observable +final class MapInteractionViewModel { + // Default region: Continental US + static let defaultRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), + span: MKCoordinateSpan(latitudeDelta: 35, longitudeDelta: 55) + ) + + // City-level zoom span + static let stadiumZoomSpan = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + + var region: MKCoordinateRegion = MapInteractionViewModel.defaultRegion + var hasUserInteracted: Bool = false + var selectedStadiumId: String? + + var shouldShowResetButton: Bool { + hasUserInteracted + } + + func userDidInteract() { + hasUserInteracted = true + } + + func resetToDefault() { + withAnimation(.easeInOut(duration: 0.5)) { + region = MapInteractionViewModel.defaultRegion + } + hasUserInteracted = false + selectedStadiumId = nil + } + + func zoomToStadium(at coordinate: CLLocationCoordinate2D) { + withAnimation(.easeInOut(duration: 0.3)) { + region = MKCoordinateRegion( + center: coordinate, + span: MapInteractionViewModel.stadiumZoomSpan + ) + } + hasUserInteracted = true + } + + func selectStadium(id: String, coordinate: CLLocationCoordinate2D) { + selectedStadiumId = id + zoomToStadium(at: coordinate) + } + + func deselectStadium() { + selectedStadiumId = nil + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ProgressMapViewTests test 2>&1 | tail -20` + +Expected: PASS + +**Step 3: Commit view model** + +```bash +git add SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift +git commit -m "feat: add MapInteractionViewModel for map interactions" +``` + +--- + +### Task 3: Update ProgressMapView to Enable Interactions + +**Files:** +- Modify: `SportsTime/Features/Progress/Views/ProgressMapView.swift` + +**Step 1: Read the current file** + +Read `SportsTime/Features/Progress/Views/ProgressMapView.swift` to understand its structure. + +**Step 2: Enable map interactions and add reset button** + +Update the Map view to: +1. Replace `interactionModes: []` with `interactionModes: [.zoom, .pan]` +2. Add `@State private var mapViewModel = MapInteractionViewModel()` +3. Add `@Binding var region` to track map region +4. Add `onMapCameraChange` handler to detect user interaction +5. Add floating "Reset View" button overlay + +The Map should look something like: + +```swift +Map(position: $cameraPosition, interactionModes: [.zoom, .pan]) { + // ... existing annotations ... +} +.onMapCameraChange { context in + mapViewModel.userDidInteract() +} +.overlay(alignment: .bottomTrailing) { + if mapViewModel.shouldShowResetButton { + Button { + withAnimation { + cameraPosition = .region(MapInteractionViewModel.defaultRegion) + mapViewModel.resetToDefault() + } + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.title3) + .padding(12) + .background(.regularMaterial, in: Circle()) + } + .padding() + } +} +``` + +**Step 3: Update annotation tap handling** + +When tapping a stadium pin, call `mapViewModel.selectStadium(id:coordinate:)` to zoom and select. + +**Step 4: Verify build succeeds** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: BUILD SUCCEEDED + +**Step 5: Commit changes** + +```bash +git add SportsTime/Features/Progress/Views/ProgressMapView.swift +git commit -m "feat: enable zoom/pan on progress map with reset button" +``` + +--- + +## Phase 2: Multiple Visits Per Stadium + +### Task 4: Create Visit List Tests + +**Files:** +- Create: `SportsTimeTests/Features/Progress/VisitListTests.swift` + +**Step 1: Write tests for multiple visits display** + +```swift +import XCTest +import SwiftData +@testable import SportsTime + +@MainActor +final class VisitListTests: XCTestCase { + var modelContainer: ModelContainer! + var modelContext: ModelContext! + + override func setUp() async throws { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + modelContainer = try ModelContainer( + for: StadiumVisit.self, Achievement.self, UserPreferences.self, + configurations: config + ) + modelContext = modelContainer.mainContext + } + + override func tearDown() async throws { + modelContainer = nil + modelContext = nil + } + + func test_VisitsForStadium_ReturnsAllVisitsSortedByDate() async throws { + // Given: Multiple visits to the same stadium + let stadiumId = "yankee-stadium" + + let visit1 = StadiumVisit( + stadiumId: stadiumId, + stadiumNameAtVisit: "Yankee Stadium", + visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago + visitType: .game, + dataSource: .manual + ) + + let visit2 = StadiumVisit( + stadiumId: stadiumId, + stadiumNameAtVisit: "Yankee Stadium", + visitDate: Date().addingTimeInterval(-86400 * 7), // 7 days ago + visitType: .game, + dataSource: .manual + ) + + let visit3 = StadiumVisit( + stadiumId: stadiumId, + stadiumNameAtVisit: "Yankee Stadium", + visitDate: Date(), // today + visitType: .tour, + dataSource: .manual + ) + + modelContext.insert(visit1) + modelContext.insert(visit2) + modelContext.insert(visit3) + try modelContext.save() + + // When: Fetching visits for that stadium + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.stadiumId == stadiumId }, + sortBy: [SortDescriptor(\.visitDate, order: .reverse)] + ) + let visits = try modelContext.fetch(descriptor) + + // Then: All visits returned, most recent first + XCTAssertEqual(visits.count, 3, "Should return all 3 visits") + XCTAssertEqual(visits[0].visitType, .tour, "Most recent visit should be first") + XCTAssertEqual(visits[2].visitType, .game, "Oldest visit should be last") + } + + func test_VisitCountForStadium_ReturnsCorrectCount() async throws { + // Given: 3 visits to one stadium, 1 to another + let stadium1 = "yankee-stadium" + let stadium2 = "fenway-park" + + for i in 0..<3 { + let visit = StadiumVisit( + stadiumId: stadium1, + stadiumNameAtVisit: "Yankee Stadium", + visitDate: Date().addingTimeInterval(Double(-i * 86400)), + visitType: .game, + dataSource: .manual + ) + modelContext.insert(visit) + } + + let fenwayVisit = StadiumVisit( + stadiumId: stadium2, + stadiumNameAtVisit: "Fenway Park", + visitDate: Date(), + visitType: .game, + dataSource: .manual + ) + modelContext.insert(fenwayVisit) + try modelContext.save() + + // When: Counting visits per stadium + let yankeeDescriptor = FetchDescriptor( + predicate: #Predicate { $0.stadiumId == stadium1 } + ) + let fenwayDescriptor = FetchDescriptor( + predicate: #Predicate { $0.stadiumId == stadium2 } + ) + + let yankeeCount = try modelContext.fetchCount(yankeeDescriptor) + let fenwayCount = try modelContext.fetchCount(fenwayDescriptor) + + // Then: Correct counts + XCTAssertEqual(yankeeCount, 3) + XCTAssertEqual(fenwayCount, 1) + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/VisitListTests test 2>&1 | tail -20` + +Expected: PASS (tests existing functionality) + +**Step 3: Commit tests** + +```bash +git add SportsTimeTests/Features/Progress/VisitListTests.swift +git commit -m "test: add tests for multiple visits per stadium" +``` + +--- + +### Task 5: Create VisitListCard Component + +**Files:** +- Create: `SportsTime/Features/Progress/Views/Components/VisitListCard.swift` + +**Step 1: Create compact visit card for lists** + +```swift +import SwiftUI + +struct VisitListCard: View { + let visit: StadiumVisit + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header row (always visible) + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack { + // Date + VStack(alignment: .leading, spacing: 2) { + Text(visit.visitDate.formatted(date: .abbreviated, time: .omitted)) + .font(.subheadline.bold()) + Text(visit.visitType.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Game info preview (if available) + if let gameInfo = visit.gameInfo { + Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + // Chevron + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding() + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + // Expanded content + if isExpanded { + Divider() + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 12) { + // Game details + if let gameInfo = visit.gameInfo { + GameInfoRow(gameInfo: gameInfo) + } + + // Seat location + if let seat = visit.seatLocation, !seat.isEmpty { + InfoRow(icon: "seat.airdrop", label: "Seat", value: seat) + } + + // Notes + if let notes = visit.notes, !notes.isEmpty { + InfoRow(icon: "note.text", label: "Notes", value: notes) + } + + // Photos count + if !visit.photos.isEmpty { + InfoRow( + icon: "photo.on.rectangle", + label: "Photos", + value: "\(visit.photos.count) photo\(visit.photos.count == 1 ? "" : "s")" + ) + } + } + .padding() + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +private struct GameInfoRow: View { + let gameInfo: StadiumVisit.GameInfo + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)") + .font(.subheadline.bold()) + + if let score = gameInfo.finalScore { + Text("Final: \(score)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + } +} + +private struct InfoRow: View { + let icon: String + let label: String + let value: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + + Text(value) + .font(.caption) + .lineLimit(2) + } + } +} + +#Preview { + let visit = StadiumVisit( + stadiumId: "yankee-stadium", + stadiumNameAtVisit: "Yankee Stadium", + visitDate: Date(), + visitType: .game, + dataSource: .manual + ) + visit.seatLocation = "Section 203, Row A, Seat 5" + visit.notes = "Great game! Saw a home run." + + return VStack { + VisitListCard(visit: visit) + } + .padding() + .background(Color(.systemGroupedBackground)) +} +``` + +**Step 2: Verify build succeeds** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: BUILD SUCCEEDED + +**Step 3: Commit component** + +```bash +git add SportsTime/Features/Progress/Views/Components/VisitListCard.swift +git commit -m "feat: add VisitListCard component for visit history" +``` + +--- + +### Task 6: Create StadiumVisitHistoryView + +**Files:** +- Create: `SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift` + +**Step 1: Create the visit history view** + +```swift +import SwiftUI +import SwiftData + +struct StadiumVisitHistoryView: View { + let stadium: Stadium + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var visits: [StadiumVisit] = [] + @State private var isLoading = true + @State private var showingAddVisit = false + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView() + } else if visits.isEmpty { + EmptyVisitHistoryView() + } else { + VisitHistoryList(visits: visits) + } + } + .navigationTitle(stadium.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + + ToolbarItem(placement: .primaryAction) { + Button { + showingAddVisit = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showingAddVisit) { + StadiumVisitSheet(preselectedStadium: stadium) + } + } + .task { + await loadVisits() + } + } + + private func loadVisits() async { + isLoading = true + defer { isLoading = false } + + let stadiumId = stadium.id + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.stadiumId == stadiumId }, + sortBy: [SortDescriptor(\.visitDate, order: .reverse)] + ) + + do { + visits = try modelContext.fetch(descriptor) + } catch { + visits = [] + } + } +} + +private struct VisitHistoryList: View { + let visits: [StadiumVisit] + + var body: some View { + ScrollView { + VStack(spacing: 12) { + // Visit count header + HStack { + Text("\(visits.count) Visit\(visits.count == 1 ? "" : "s")") + .font(.headline) + Spacer() + } + .padding(.horizontal) + + // Visit cards + ForEach(visits, id: \.id) { visit in + VisitListCard(visit: visit) + .padding(.horizontal) + } + } + .padding(.vertical) + } + .background(Color(.systemGroupedBackground)) + } +} + +private struct EmptyVisitHistoryView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "calendar.badge.plus") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text("No visits recorded") + .font(.headline) + + Text("Tap + to add your first visit to this stadium") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } +} +``` + +**Step 2: Verify build succeeds** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: BUILD SUCCEEDED + +**Step 3: Commit view** + +```bash +git add SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift +git commit -m "feat: add StadiumVisitHistoryView for multiple visits" +``` + +--- + +### Task 7: Add Visit Count Badge to Stadium Cards + +**Files:** +- Modify: `SportsTime/Features/Progress/Views/ProgressTabView.swift` + +**Step 1: Read ProgressTabView to find stadium card/chip component** + +Read the file to identify where visited stadium chips are displayed. + +**Step 2: Add visit count badge** + +Where visited stadiums are shown as chips, add a badge showing visit count: + +```swift +// Example modification to stadium chip +HStack(spacing: 4) { + Text(stadiumName) + .font(.caption) + + // Visit count badge (if more than 1) + if visitCount > 1 { + Text("\(visitCount)") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange, in: Capsule()) + } +} +``` + +**Step 3: Update tap action to show history** + +When tapping a visited stadium, present `StadiumVisitHistoryView` instead of just the most recent visit. + +**Step 4: Verify build succeeds** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: BUILD SUCCEEDED + +**Step 5: Commit changes** + +```bash +git add SportsTime/Features/Progress/Views/ProgressTabView.swift +git commit -m "feat: add visit count badges and history view navigation" +``` + +--- + +## Phase 3: Games History View + +### Task 8: Create GamesHistoryViewModel Test + +**Files:** +- Create: `SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift` + +**Step 1: Write tests for games history** + +```swift +import XCTest +import SwiftData +@testable import SportsTime + +@MainActor +final class GamesHistoryViewModelTests: XCTestCase { + var modelContainer: ModelContainer! + var modelContext: ModelContext! + + override func setUp() async throws { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + modelContainer = try ModelContainer( + for: StadiumVisit.self, Achievement.self, UserPreferences.self, + configurations: config + ) + modelContext = modelContainer.mainContext + } + + override func tearDown() async throws { + modelContainer = nil + modelContext = nil + } + + func test_GamesHistoryViewModel_GroupsVisitsByYear() async throws { + // Given: Visits in different years + let visit2026 = StadiumVisit( + stadiumId: "stadium-1", + stadiumNameAtVisit: "Stadium 2026", + visitDate: Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 15))!, + visitType: .game, + dataSource: .manual + ) + + let visit2025 = StadiumVisit( + stadiumId: "stadium-2", + stadiumNameAtVisit: "Stadium 2025", + visitDate: Calendar.current.date(from: DateComponents(year: 2025, month: 6, day: 15))!, + visitType: .game, + dataSource: .manual + ) + + modelContext.insert(visit2026) + modelContext.insert(visit2025) + try modelContext.save() + + // When: Loading games history + let viewModel = GamesHistoryViewModel(modelContext: modelContext) + await viewModel.loadGames() + + // Then: Visits are grouped by year + XCTAssertEqual(viewModel.visitsByYear.keys.count, 2, "Should have 2 years") + XCTAssertTrue(viewModel.visitsByYear.keys.contains(2026)) + XCTAssertTrue(viewModel.visitsByYear.keys.contains(2025)) + } + + func test_GamesHistoryViewModel_FiltersBySport() async throws { + // Given: Visits to different sport stadiums + // Note: This requires stadiums in AppDataProvider to map stadiumId → sport + let mlbVisit = StadiumVisit( + stadiumId: "yankee-stadium", + stadiumNameAtVisit: "Yankee Stadium", + visitDate: Date(), + visitType: .game, + dataSource: .manual + ) + + modelContext.insert(mlbVisit) + try modelContext.save() + + // When: Filtering by MLB + let viewModel = GamesHistoryViewModel(modelContext: modelContext) + await viewModel.loadGames() + viewModel.selectedSports = [.mlb] + + // Then: Only MLB visits shown + let filteredCount = viewModel.filteredVisits.count + XCTAssertGreaterThanOrEqual(filteredCount, 0, "Filter should work without crashing") + } + + func test_GamesHistoryViewModel_SortsMostRecentFirst() async throws { + // Given: Visits on different dates + let oldVisit = StadiumVisit( + stadiumId: "stadium-1", + stadiumNameAtVisit: "Old Stadium", + visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago + visitType: .game, + dataSource: .manual + ) + + let newVisit = StadiumVisit( + stadiumId: "stadium-2", + stadiumNameAtVisit: "New Stadium", + visitDate: Date(), + visitType: .game, + dataSource: .manual + ) + + modelContext.insert(oldVisit) + modelContext.insert(newVisit) + try modelContext.save() + + // When: Loading games + let viewModel = GamesHistoryViewModel(modelContext: modelContext) + await viewModel.loadGames() + + // Then: Most recent first within year + let visits = viewModel.allVisits + XCTAssertEqual(visits.first?.stadiumNameAtVisit, "New Stadium", "Most recent should be first") + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/GamesHistoryViewModelTests test 2>&1 | tail -20` + +Expected: FAIL with "Cannot find 'GamesHistoryViewModel' in scope" + +**Step 3: Commit tests** + +```bash +git add SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift +git commit -m "test: add GamesHistoryViewModel tests" +``` + +--- + +### Task 9: Create GamesHistoryViewModel + +**Files:** +- Create: `SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift` + +**Step 1: Create the view model** + +```swift +import SwiftUI +import SwiftData + +@MainActor +@Observable +final class GamesHistoryViewModel { + private let modelContext: ModelContext + + var allVisits: [StadiumVisit] = [] + var selectedSports: Set = [] + var isLoading = false + var error: String? + + // Computed: visits grouped by year + var visitsByYear: [Int: [StadiumVisit]] { + let calendar = Calendar.current + let filtered = filteredVisits + return Dictionary(grouping: filtered) { visit in + calendar.component(.year, from: visit.visitDate) + } + } + + // Computed: sorted year keys (descending) + var sortedYears: [Int] { + visitsByYear.keys.sorted(by: >) + } + + // Computed: filtered by selected sports + var filteredVisits: [StadiumVisit] { + guard !selectedSports.isEmpty else { return allVisits } + + return allVisits.filter { visit in + guard let stadium = AppDataProvider.shared.stadium(byId: visit.stadiumId) else { + return false + } + return selectedSports.contains(stadium.sport) + } + } + + // Total count + var totalGamesCount: Int { + filteredVisits.count + } + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func loadGames() async { + isLoading = true + defer { isLoading = false } + + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.visitDate, order: .reverse)] + ) + + do { + allVisits = try modelContext.fetch(descriptor) + } catch { + self.error = "Failed to load games: \(error.localizedDescription)" + allVisits = [] + } + } + + func toggleSport(_ sport: Sport) { + if selectedSports.contains(sport) { + selectedSports.remove(sport) + } else { + selectedSports.insert(sport) + } + } + + func clearFilters() { + selectedSports.removeAll() + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/GamesHistoryViewModelTests test 2>&1 | tail -20` + +Expected: PASS + +**Step 3: Commit view model** + +```bash +git add SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift +git commit -m "feat: add GamesHistoryViewModel with year grouping and sport filtering" +``` + +--- + +### Task 10: Create GamesHistoryRow Component + +**Files:** +- Create: `SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift` + +**Step 1: Create the row component** + +```swift +import SwiftUI + +struct GamesHistoryRow: View { + let visit: StadiumVisit + let stadium: Stadium? + + var body: some View { + HStack(spacing: 12) { + // Sport icon + if let stadium { + Image(systemName: sportIcon(for: stadium.sport)) + .font(.title3) + .foregroundStyle(stadium.sport.color) + .frame(width: 32) + } + + // Visit info + VStack(alignment: .leading, spacing: 4) { + // Date + Text(visit.visitDate.formatted(date: .abbreviated, time: .omitted)) + .font(.subheadline.bold()) + + // Teams (if game) + if let gameInfo = visit.gameInfo { + Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(visit.stadiumNameAtVisit) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Score (if available) + if let gameInfo = visit.gameInfo, let score = gameInfo.finalScore { + Text(score) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + + // Chevron + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private func sportIcon(for sport: Sport) -> String { + switch sport { + case .mlb: return "baseball" + case .nba: return "basketball" + case .nhl: return "hockey.puck" + case .nfl: return "football" + case .mls: return "soccerball" + } + } +} + +#Preview { + let visit = StadiumVisit( + stadiumId: "yankee-stadium", + stadiumNameAtVisit: "Yankee Stadium", + visitDate: Date(), + visitType: .game, + dataSource: .manual + ) + + GamesHistoryRow( + visit: visit, + stadium: nil + ) + .padding() + .background(Color(.systemGroupedBackground)) +} +``` + +**Step 2: Verify build succeeds** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: BUILD SUCCEEDED + +**Step 3: Commit component** + +```bash +git add SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift +git commit -m "feat: add GamesHistoryRow component" +``` + +--- + +### Task 11: Create GamesHistoryView + +**Files:** +- Create: `SportsTime/Features/Progress/Views/GamesHistoryView.swift` + +**Step 1: Create the games history view** + +```swift +import SwiftUI +import SwiftData + +struct GamesHistoryView: View { + @Environment(\.modelContext) private var modelContext + @State private var viewModel: GamesHistoryViewModel? + @State private var selectedVisit: StadiumVisit? + + var body: some View { + NavigationStack { + Group { + if let viewModel { + GamesHistoryContent( + viewModel: viewModel, + selectedVisit: $selectedVisit + ) + } else { + ProgressView("Loading games...") + } + } + .navigationTitle("Games Attended") + .sheet(item: $selectedVisit) { visit in + VisitDetailView(visit: visit) + } + } + .task { + if viewModel == nil { + let vm = GamesHistoryViewModel(modelContext: modelContext) + await vm.loadGames() + viewModel = vm + } + } + } +} + +private struct GamesHistoryContent: View { + @Bindable var viewModel: GamesHistoryViewModel + @Binding var selectedVisit: StadiumVisit? + + var body: some View { + VStack(spacing: 0) { + // Header with count and filters + VStack(spacing: 12) { + // Total count + HStack { + Text("\(viewModel.totalGamesCount) Games") + .font(.headline) + Spacer() + + if !viewModel.selectedSports.isEmpty { + Button("Clear") { + viewModel.clearFilters() + } + .font(.caption) + } + } + + // Sport filter chips + SportFilterChips( + selectedSports: viewModel.selectedSports, + onToggle: viewModel.toggleSport + ) + } + .padding() + .background(Color(.systemBackground)) + + Divider() + + // Games list grouped by year + if viewModel.filteredVisits.isEmpty { + EmptyGamesView() + } else { + GamesListByYear( + visitsByYear: viewModel.visitsByYear, + sortedYears: viewModel.sortedYears, + onSelect: { visit in + selectedVisit = visit + } + ) + } + } + .background(Color(.systemGroupedBackground)) + } +} + +private struct SportFilterChips: View { + let selectedSports: Set + let onToggle: (Sport) -> Void + + private let sports: [Sport] = [.mlb, .nba, .nhl] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(sports, id: \.self) { sport in + SportChip( + sport: sport, + isSelected: selectedSports.contains(sport), + onTap: { onToggle(sport) } + ) + } + } + } + } +} + +private struct SportChip: View { + let sport: Sport + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 4) { + Image(systemName: sportIcon) + .font(.caption) + Text(sport.abbreviation) + .font(.caption.bold()) + } + .foregroundStyle(isSelected ? .white : .primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(isSelected ? sport.color : Color(.systemGray5)) + ) + } + .buttonStyle(.plain) + } + + private var sportIcon: String { + switch sport { + case .mlb: return "baseball" + case .nba: return "basketball" + case .nhl: return "hockey.puck" + case .nfl: return "football" + case .mls: return "soccerball" + } + } +} + +private struct GamesListByYear: View { + let visitsByYear: [Int: [StadiumVisit]] + let sortedYears: [Int] + let onSelect: (StadiumVisit) -> Void + + var body: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { + ForEach(sortedYears, id: \.self) { year in + Section { + VStack(spacing: 8) { + ForEach(visitsByYear[year] ?? [], id: \.id) { visit in + Button { + onSelect(visit) + } label: { + GamesHistoryRow( + visit: visit, + stadium: AppDataProvider.shared.stadium(byId: visit.stadiumId) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } header: { + YearHeader(year: year) + } + } + } + } + } +} + +private struct YearHeader: View { + let year: Int + + var body: some View { + HStack { + Text(String(year)) + .font(.title3.bold()) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(.systemGroupedBackground)) + } +} + +private struct EmptyGamesView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "ticket") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text("No games recorded yet") + .font(.headline) + + Text("Add your first stadium visit to see it here!") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} +``` + +**Step 2: Verify build succeeds** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: BUILD SUCCEEDED + +**Step 3: Commit view** + +```bash +git add SportsTime/Features/Progress/Views/GamesHistoryView.swift +git commit -m "feat: add GamesHistoryView with year grouping and sport filters" +``` + +--- + +### Task 12: Add Navigation to GamesHistoryView + +**Files:** +- Modify: `SportsTime/Features/Progress/Views/ProgressTabView.swift` + +**Step 1: Read ProgressTabView** + +Read the file to find the header area and "Recent Visits" section. + +**Step 2: Add navigation links** + +Add a button in the header or toolbar to access GamesHistoryView: + +```swift +// In toolbar or header +NavigationLink(destination: GamesHistoryView()) { + Label("All Games", systemImage: "list.bullet") +} + +// Or a "See All" link in Recent Visits section +HStack { + Text("Recent Visits") + .font(.headline) + Spacer() + NavigationLink("See All") { + GamesHistoryView() + } + .font(.caption) +} +``` + +**Step 3: Verify build succeeds** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: BUILD SUCCEEDED + +**Step 4: Commit changes** + +```bash +git add SportsTime/Features/Progress/Views/ProgressTabView.swift +git commit -m "feat: add navigation to GamesHistoryView from Progress tab" +``` + +--- + +## Phase 4: Final Integration and Testing + +### Task 13: Run Full Test Suite + +**Step 1: Run all tests** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50` + +Expected: All tests pass + +**Step 2: Fix any failures** + +If any tests fail, investigate and fix them: + +```bash +# If fixes needed +git add -A +git commit -m "fix: address test failures in progress tracking enhancements" +``` + +--- + +### Task 14: Verify Build and Commit Feature + +**Step 1: Final build verification** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: BUILD SUCCEEDED + +**Step 2: Final commit** + +```bash +git add -A +git commit -m "feat: complete progress tracking enhancements + +- Enable zoom/pan on progress map with reset button +- Add multiple visits per stadium display with visit count badges +- Create GamesHistoryView with year grouping and sport filters +- Add StadiumVisitHistoryView for viewing all visits to a stadium +- Add VisitListCard and GamesHistoryRow components" +``` + +--- + +## Summary + +This plan implements three features: + +1. **Zoomable Map** (Tasks 1-3) + - Enable zoom/pan interactions on ProgressMapView + - Add MapInteractionViewModel for state management + - Add floating "Reset View" button + +2. **Multiple Visits Per Stadium** (Tasks 4-7) + - Create VisitListCard expandable component + - Create StadiumVisitHistoryView for full visit history + - Add visit count badges to stadium chips + - Update tap handling to show history view + +3. **Games History View** (Tasks 8-12) + - Create GamesHistoryViewModel with year grouping + - Create GamesHistoryRow component + - Create GamesHistoryView with sport filter chips + - Add navigation from Progress tab + +**Files Created:** +- `SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift` +- `SportsTime/Features/Progress/Views/Components/VisitListCard.swift` +- `SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift` +- `SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift` +- `SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift` +- `SportsTime/Features/Progress/Views/GamesHistoryView.swift` + +**Files Modified:** +- `SportsTime/Features/Progress/Views/ProgressMapView.swift` +- `SportsTime/Features/Progress/Views/ProgressTabView.swift` + +**Tests Created:** +- `SportsTimeTests/Features/Progress/ProgressMapViewTests.swift` +- `SportsTimeTests/Features/Progress/VisitListTests.swift` +- `SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift` diff --git a/docs/plans/2026-01-12-todo-bugs-implementation.md b/docs/plans/2026-01-12-todo-bugs-implementation.md new file mode 100644 index 0000000..2666453 --- /dev/null +++ b/docs/plans/2026-01-12-todo-bugs-implementation.md @@ -0,0 +1,799 @@ +# TODO Bug Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix 9 bugs affecting app performance, UI polish, and data pipeline. + +**Architecture:** Refactor `SuggestedTripsGenerator` to run heavy work off main actor; add pagination to large lists; fix UI display issues and animations; add stadium timezone support; add scraper alias. + +**Tech Stack:** Swift, SwiftUI, @Observable, async/await, Python (scraper) + +--- + +## Task 1: Fix Launch Freeze (Bug 1) + +**Files:** +- Modify: `SportsTime/Core/Services/SuggestedTripsGenerator.swift` + +**Step 1: Identify the blocking code** + +The entire `generateTrips()` method runs on `@MainActor`. We need to move heavy computation to a background task while keeping UI updates on main. + +**Step 2: Create a non-isolated helper struct for planning** + +Add this struct above `SuggestedTripsGenerator`: + +```swift +// Non-isolated helper for background trip generation +private struct TripGenerationHelper: Sendable { + let stadiums: [Stadium] + let teams: [Team] + let games: [Game] + + func generateRegionalTrips( + startDate: Date, + endDate: Date + ) -> [SuggestedTrip] { + let planningEngine = TripPlanningEngine() + let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 } + let teamsById = teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 } + + var generatedTrips: [SuggestedTrip] = [] + + // Regional trips (copy existing logic from generateTrips) + for region in [Region.east, Region.central, Region.west] { + let regionStadiumIds = Set( + stadiums + .filter { $0.region == region } + .map { $0.id } + ) + let regionGames = games.filter { regionStadiumIds.contains($0.stadiumId) } + guard !regionGames.isEmpty else { continue } + + // Generate trips using existing private methods (will need to move them) + } + + return generatedTrips + } +} +``` + +**Step 3: Refactor generateTrips() to use Task.detached** + +Replace the heavy computation section in `generateTrips()`: + +```swift +func generateTrips() async { + guard !isLoading else { return } + + isLoading = true + error = nil + suggestedTrips = [] + loadingMessage = await loadingTextGenerator.generateMessage() + + // Ensure data is loaded + if dataProvider.teams.isEmpty { + await dataProvider.loadInitialData() + } + + guard !dataProvider.stadiums.isEmpty else { + error = "Unable to load stadium data" + isLoading = false + return + } + + let calendar = Calendar.current + let today = Date() + guard let startDate = calendar.date(byAdding: .weekOfYear, value: 4, to: today), + let endDate = calendar.date(byAdding: .weekOfYear, value: 8, to: today) else { + error = "Failed to calculate date range" + isLoading = false + return + } + + do { + let allSports = Set(Sport.supported) + let games = try await dataProvider.filterGames( + sports: allSports, + startDate: startDate, + endDate: endDate + ) + + guard !games.isEmpty else { + error = "No games found in the next 4-8 weeks" + isLoading = false + return + } + + // Capture data for background work + let stadiums = dataProvider.stadiums + let teams = dataProvider.teams + + // Heavy computation off main actor + let generatedTrips = await Task.detached(priority: .userInitiated) { + self.generateTripsInBackground( + games: games, + stadiums: stadiums, + teams: teams, + startDate: startDate, + endDate: endDate + ) + }.value + + suggestedTrips = generatedTrips + } catch { + self.error = "Failed to generate trips: \(error.localizedDescription)" + } + + isLoading = false +} + +// New nonisolated method for background work +nonisolated private func generateTripsInBackground( + games: [Game], + stadiums: [Stadium], + teams: [Team], + startDate: Date, + endDate: Date +) -> [SuggestedTrip] { + let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 } + let teamsById = teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 } + + var generatedTrips: [SuggestedTrip] = [] + + // Copy existing regional trip generation logic here... + // (Move the for loop from the original generateTrips) + + return generatedTrips +} +``` + +**Step 4: Move helper methods to be nonisolated** + +Mark these methods as `nonisolated`: +- `generateRegionalTrip` +- `generateCrossCountryTrip` +- `buildRichGames` +- `buildCoastToCoastRoute` +- `buildTripStop` +- `buildTravelSegments` +- `convertToTrip` +- `generateTripName` +- `buildCorridorTrip` +- `haversineDistance` +- `validateNoSameDayConflicts` + +**Step 5: Build and test** + +Run: +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build +``` + +Expected: Build succeeds + +**Step 6: Manual test** + +Launch app in simulator. UI should be responsive immediately while "Suggested Trips" section shows loading state. + +**Step 7: Commit** + +```bash +git add SportsTime/Core/Services/SuggestedTripsGenerator.swift +git commit -m "perf: move trip generation off main actor + +Fixes launch freeze by running TripPlanningEngine in background task. +UI now responsive immediately while trips generate." +``` + +--- + +## Task 2: Add Location Info to "By Game" View (Bug 3) + +**Files:** +- Modify: `SportsTime/Features/Schedule/Views/ScheduleListView.swift` + +**Step 1: Find GameRowView and add showLocation parameter** + +Locate `GameRowView` in the file (or it may be in a separate file). Add parameter: + +```swift +struct GameRowView: View { + let game: RichGame + var showDate: Bool = false + var showLocation: Bool = false // Add this +``` + +**Step 2: Add location display in the view body** + +After the existing content, add: + +```swift +var body: some View { + VStack(alignment: .leading, spacing: 4) { + // ... existing matchup content + + if showLocation { + HStack(spacing: 4) { + Image(systemName: "mappin.circle.fill") + .font(.caption2) + .foregroundStyle(.tertiary) + Text("\(game.stadium.name), \(game.stadium.city)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} +``` + +**Step 3: Pass showLocation when groupBy is byGame** + +Find where `GameRowView` is called and update: + +```swift +// When rendering games in "by game" mode: +GameRowView(game: richGame, showDate: true, showLocation: groupBy == .byGame) +``` + +**Step 4: Build and verify** + +Run: +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build +``` + +**Step 5: Manual test** + +Open Schedule tab, switch to "By Game" grouping. Each row should now show stadium name and city. + +**Step 6: Commit** + +```bash +git add SportsTime/Features/Schedule/Views/ScheduleListView.swift +git commit -m "feat: show location info in By Game schedule view + +Adds stadium name and city to game rows when grouped by game." +``` + +--- + +## Task 3: Fix Game Selection Animation (Bug 5) + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripCreationView.swift` + +**Step 1: Find the game selection tap handler** + +Look for the game selection list/grid and its tap action. + +**Step 2: Wrap selection in transaction to disable animation** + +```swift +// Replace direct toggle: +// viewModel.toggleGameSelection(game) + +// With: +var transaction = Transaction() +transaction.disablesAnimations = true +withTransaction(transaction) { + viewModel.toggleGameSelection(game) +} +``` + +**Step 3: Alternative - add animation nil modifier** + +If using a selection indicator (checkmark), add: + +```swift +Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? .blue : .secondary) + .animation(nil, value: isSelected) +``` + +**Step 4: Build and test** + +Run: +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build +``` + +**Step 5: Manual test** + +Go to trip creation, select "By Game" mode, tap games to select. Animation should be instant without weird morphing. + +**Step 6: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripCreationView.swift +git commit -m "fix: remove weird animation on game selection + +Disables implicit animation when toggling game selection." +``` + +--- + +## Task 4: Fix Pace Capsule Animation (Bug 7) + +**Files:** +- Modify: `SportsTime/Features/Home/Views/SavedTripsListView.swift` (or similar) + +**Step 1: Find the pace capsule component** + +Search for "packed", "moderate", "relaxed" or pace-related text in the trips list view. + +**Step 2: Add contentTransition and disable animation** + +```swift +Text(trip.paceLabel) // or whatever the pace text is + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(paceColor.opacity(0.2)) + .foregroundStyle(paceColor) + .clipShape(Capsule()) + .contentTransition(.identity) // Prevents text morphing + .animation(nil, value: trip.id) // No animation on data change +``` + +**Step 3: Build and test** + +Run: +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build +``` + +**Step 4: Manual test** + +View saved trips list. Pace capsules should appear without glitchy animation. + +**Step 5: Commit** + +```bash +git add SportsTime/Features/Home/Views/SavedTripsListView.swift +git commit -m "fix: pace capsule animation glitch + +Adds contentTransition(.identity) to prevent text morphing." +``` + +--- + +## Task 5: Remove "My Trips" Title (Bug 8) + +**Files:** +- Modify: `SportsTime/Features/Home/Views/HomeView.swift` (or main TabView file) + +**Step 1: Find the My Trips tab** + +Look for TabView and the "My Trips" tab item. + +**Step 2: Remove or hide navigation title** + +Option A - Remove `.navigationTitle()`: +```swift +NavigationStack { + SavedTripsListView(trips: savedTrips) + // Remove: .navigationTitle("My Trips") +} +.tabItem { + Label("My Trips", systemImage: "suitcase") +} +``` + +Option B - Hide navigation bar: +```swift +NavigationStack { + SavedTripsListView(trips: savedTrips) + .toolbar(.hidden, for: .navigationBar) +} +.tabItem { + Label("My Trips", systemImage: "suitcase") +} +``` + +**Step 3: Build and test** + +Run: +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build +``` + +**Step 4: Manual test** + +Switch to My Trips tab. No redundant title should appear above the content. + +**Step 5: Commit** + +```bash +git add SportsTime/Features/Home/Views/HomeView.swift +git commit -m "fix: remove redundant My Trips title from tab view" +``` + +--- + +## Task 6: Add Stadium Timezone Support (Bug 6) + +**Files:** +- Modify: `SportsTime/Core/Models/Domain/Stadium.swift` +- Modify: `SportsTime/Core/Models/Domain/Game.swift` +- Modify: `Scripts/sportstime_parser/normalizers/stadium_resolver.py` + +**Step 1: Add timeZoneIdentifier to Stadium model** + +```swift +struct Stadium: Identifiable, Codable, Hashable { + let id: String + let name: String + let city: String + let state: String + let coordinate: CLLocationCoordinate2D + let sport: Sport + let region: Region + let timeZoneIdentifier: String? // Add this + + var timeZone: TimeZone? { + timeZoneIdentifier.flatMap { TimeZone(identifier: $0) } + } + + // Update init to include timeZoneIdentifier with default nil for backward compatibility + init( + id: String, + name: String, + city: String, + state: String, + coordinate: CLLocationCoordinate2D, + sport: Sport, + region: Region, + timeZoneIdentifier: String? = nil + ) { + self.id = id + self.name = name + self.city = city + self.state = state + self.coordinate = coordinate + self.sport = sport + self.region = region + self.timeZoneIdentifier = timeZoneIdentifier + } +} +``` + +**Step 2: Add localGameTime to RichGame** + +```swift +extension RichGame { + var localGameTime: String { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a z" + formatter.timeZone = stadium.timeZone ?? .current + return formatter.string(from: game.dateTime) + } + + var localGameTimeShort: String { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.timeZone = stadium.timeZone ?? .current + return formatter.string(from: game.dateTime) + } +} +``` + +**Step 3: Update Python scraper StadiumInfo** + +In `stadium_resolver.py`, update the dataclass and all stadium entries: + +```python +@dataclass +class StadiumInfo: + canonical_id: str + name: str + city: str + state: str + country: str + sport: str + latitude: float + longitude: float + timezone: str = "America/New_York" # Add with default + +# Update entries, e.g.: +"stadium_nba_chase_center": StadiumInfo("stadium_nba_chase_center", "Chase Center", "San Francisco", "CA", "USA", "nba", 37.7680, -122.3877, "America/Los_Angeles"), +``` + +**Step 4: Build Swift code** + +Run: +```bash +xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build +``` + +**Step 5: Commit** + +```bash +git add SportsTime/Core/Models/Domain/Stadium.swift SportsTime/Core/Models/Domain/Game.swift Scripts/sportstime_parser/normalizers/stadium_resolver.py +git commit -m "feat: add timezone support for stadium-local game times + +Adds timeZoneIdentifier to Stadium and localGameTime to RichGame. +Game times can now display in venue local time." +``` + +**Note:** Full timezone population for all stadiums is a separate data task. + +--- + +## Task 7: Add Frost Bank Center Alias (Bug 9) + +**Files:** +- Modify: `Scripts/stadium_aliases.json` + +**Step 1: Open stadium_aliases.json and add alias** + +Add entry for AT&T Center (old name for Frost Bank Center): + +```json +{ + "canonical_id": "stadium_nba_frost_bank_center", + "alias_name": "AT&T Center", + "sport": "nba", + "valid_from": "2002-10-18", + "valid_to": "2024-07-01", + "notes": "Renamed to Frost Bank Center in July 2024" +} +``` + +**Step 2: Verify JSON is valid** + +Run: +```bash +python3 -c "import json; json.load(open('Scripts/stadium_aliases.json'))" +``` + +Expected: No output (valid JSON) + +**Step 3: Test scraper resolution** + +Run: +```bash +cd Scripts && python3 -c " +from sportstime_parser.normalizers.stadium_resolver import resolve_stadium +result = resolve_stadium('nba', 'AT&T Center') +print(f'Resolved: {result.canonical_id}, confidence: {result.confidence}') +" +``` + +Expected: `Resolved: stadium_nba_frost_bank_center, confidence: 95` + +**Step 4: Commit** + +```bash +git add Scripts/stadium_aliases.json +git commit -m "fix: add AT&T Center alias for Frost Bank Center + +NBA scraper now resolves old stadium name to current Spurs arena." +``` + +--- + +## Task 8: Add Pagination to Schedule List (Bug 2) + +**Files:** +- Modify: `SportsTime/Features/Schedule/Views/ScheduleListView.swift` +- Modify: `SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift` + +**Step 1: Add pagination state to ViewModel** + +```swift +@Observable +class ScheduleViewModel { + // Existing properties... + + // Pagination + private let pageSize = 50 + var displayedGames: [RichGame] = [] + private var currentPage = 0 + private var allFilteredGames: [RichGame] = [] + + var hasMoreGames: Bool { + displayedGames.count < allFilteredGames.count + } + + func loadInitialGames() { + currentPage = 0 + displayedGames = Array(allFilteredGames.prefix(pageSize)) + } + + func loadMoreGames() { + guard hasMoreGames else { return } + currentPage += 1 + let start = currentPage * pageSize + let end = min(start + pageSize, allFilteredGames.count) + displayedGames.append(contentsOf: allFilteredGames[start..