fix: multiple bug fixes and improvements

- Fix suggested trips showing wrong sports for cross-country trips
- Remove quick start sections from home variants (Classic, Spotify)
- Remove dead quickActions code from HomeView
- Fix pace capsule animation in TripCreationView
- Add text wrapping to achievement descriptions
- Improve poll parsing with better error handling
- Various sharing system improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-14 09:35:18 -06:00
parent fe36f99bca
commit d034ee8612
22 changed files with 422 additions and 242 deletions

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1CA7F8F22F0D647100490ABD"
BuildableName = "SportsTime.app"
BlueprintName = "SportsTime"
ReferencedContainer = "container:SportsTime.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1CA7F9032F0D647300490ABD"
BuildableName = "SportsTimeTests.xctest"
BlueprintName = "SportsTimeTests"
ReferencedContainer = "container:SportsTime.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1CA7F90D2F0D647400490ABD"
BuildableName = "SportsTimeUITests.xctest"
BlueprintName = "SportsTimeUITests"
ReferencedContainer = "container:SportsTime.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1CA7F8F22F0D647100490ABD"
BuildableName = "SportsTime.app"
BlueprintName = "SportsTime"
ReferencedContainer = "container:SportsTime.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../SportsTime/Configuration.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1CA7F8F22F0D647100490ABD"
BuildableName = "SportsTime.app"
BlueprintName = "SportsTime"
ReferencedContainer = "container:SportsTime.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -510,17 +510,49 @@ struct CKTripPoll {
}
func toPoll() -> TripPoll? {
// Required fields with explicit guards for better debugging
guard let pollIdString = record[CKTripPoll.pollIdKey] as? String,
let pollId = UUID(uuidString: pollIdString),
let title = record[CKTripPoll.titleKey] as? String,
let ownerId = record[CKTripPoll.ownerIdKey] as? String,
let shareCode = record[CKTripPoll.shareCodeKey] as? String,
let tripsData = record[CKTripPoll.tripSnapshotsKey] as? Data,
let tripSnapshots = try? JSONDecoder().decode([Trip].self, from: tripsData),
let tripVersions = record[CKTripPoll.tripVersionsKey] as? [String],
let createdAt = record[CKTripPoll.createdAtKey] as? Date,
let modifiedAt = record[CKTripPoll.modifiedAtKey] as? Date
else { return nil }
let pollId = UUID(uuidString: pollIdString)
else {
print("CKTripPoll.toPoll: Failed to parse pollId")
return nil
}
guard let title = record[CKTripPoll.titleKey] as? String else {
print("CKTripPoll.toPoll: Missing title for poll \(pollId)")
return nil
}
guard let ownerId = record[CKTripPoll.ownerIdKey] as? String else {
print("CKTripPoll.toPoll: Missing ownerId for poll \(pollId)")
return nil
}
guard let shareCode = record[CKTripPoll.shareCodeKey] as? String else {
print("CKTripPoll.toPoll: Missing shareCode for poll \(pollId)")
return nil
}
guard let createdAt = record[CKTripPoll.createdAtKey] as? Date else {
print("CKTripPoll.toPoll: Missing createdAt for poll \(pollId)")
return nil
}
// modifiedAt defaults to createdAt if missing
let modifiedAt = record[CKTripPoll.modifiedAtKey] as? Date ?? createdAt
// Decode trip snapshots - this is the most likely failure point
var tripSnapshots: [Trip] = []
if let tripsData = record[CKTripPoll.tripSnapshotsKey] as? Data {
do {
tripSnapshots = try JSONDecoder().decode([Trip].self, from: tripsData)
} catch {
print("CKTripPoll.toPoll: Failed to decode tripSnapshots for poll \(pollId): \(error)")
// Return poll with empty trips rather than failing completely
}
} else {
print("CKTripPoll.toPoll: Missing tripSnapshots data for poll \(pollId)")
}
var poll = TripPoll(
id: pollId,
@@ -531,8 +563,13 @@ struct CKTripPoll {
createdAt: createdAt,
modifiedAt: modifiedAt
)
// Preserve the actual stored versions (not recomputed)
poll.tripVersions = tripVersions
// tripVersions is optional - it's recomputed if missing
if let storedVersions = record[CKTripPoll.tripVersionsKey] as? [String] {
poll.tripVersions = storedVersions
}
// Otherwise, keep the computed versions from init
return poll
}
}

View File

@@ -184,6 +184,13 @@ actor PollService {
}
func deletePoll(_ pollId: UUID) async throws {
// Verify ownership before deleting
let poll = try await fetchPoll(byId: pollId)
let userId = try await getCurrentUserRecordID()
guard poll.ownerId == userId else {
throw PollError.notPollOwner
}
let recordID = CKRecord.ID(recordName: pollId.uuidString)
do {
@@ -193,6 +200,8 @@ actor PollService {
try await publicDatabase.deleteRecord(withID: recordID)
} catch let error as CKError {
throw mapCloudKitError(error)
} catch let error as PollError {
throw error
} catch {
throw PollError.unknown(error)
}

View File

@@ -484,13 +484,17 @@ final class SuggestedTripsGenerator {
// Build richGames dictionary
let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums)
// Compute sports from games actually in the trip (not all selectedGames)
let gameIdsInTrip = Set(trip.stops.flatMap { $0.games })
let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport })
return SuggestedTrip(
id: UUID(),
region: .crossCountry,
isSingleSport: sports.count == 1,
isSingleSport: actualSports.count == 1,
trip: trip,
richGames: richGames,
sports: sports
sports: actualSports.isEmpty ? sports : actualSports
)
}

View File

@@ -466,7 +466,7 @@ final class PDFGenerator {
.font: UIFont.systemFont(ofSize: 11),
.foregroundColor: UIColor.darkGray
]
let venueText = "\(richGame.stadium.name) | \(richGame.game.gameTime)"
let venueText = "\(richGame.stadium.name) | \(richGame.localGameTimeShort)"
(venueText as NSString).draw(
at: CGPoint(x: margin + 110, y: currentY + 30),
withAttributes: venueAttributes

View File

@@ -137,6 +137,8 @@ private struct AchievementSpotlightView: View {
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Description
Text(achievement.definition.description)
@@ -188,7 +190,7 @@ private struct AchievementCollectionView: View {
VStack(spacing: 40) {
// Header
Text("My \(year) Achievements")
Text("My \(String(year)) Achievements")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
@@ -271,6 +273,8 @@ private struct AchievementMilestoneView: View {
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Description
Text(achievement.definition.description)

View File

@@ -13,7 +13,6 @@ import UIKit
struct ProgressShareContent: ShareableContent {
let progress: LeagueProgress
let tripCount: Int
let username: String?
var cardType: ShareCardType { .stadiumProgress }
@@ -29,7 +28,6 @@ struct ProgressShareContent: ShareableContent {
let cardView = ProgressCardView(
progress: progress,
tripCount: tripCount,
username: username,
theme: theme,
mapSnapshot: mapSnapshot
)
@@ -50,7 +48,6 @@ struct ProgressShareContent: ShareableContent {
private struct ProgressCardView: View {
let progress: LeagueProgress
let tripCount: Int
let username: String?
let theme: ShareTheme
let mapSnapshot: UIImage?
@@ -103,7 +100,7 @@ private struct ProgressCardView: View {
Spacer()
ShareCardFooter(theme: theme, username: username)
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}

View File

@@ -56,20 +56,9 @@ struct ShareCardHeader: View {
struct ShareCardFooter: View {
let theme: ShareTheme
var username: String? = nil
var body: some View {
VStack(spacing: 12) {
if let username = username, !username.isEmpty {
HStack(spacing: 8) {
Image(systemName: "person.circle.fill")
.font(.system(size: 24))
Text("@\(username)")
.font(.system(size: 28, weight: .medium))
}
.foregroundStyle(theme.secondaryTextColor)
}
HStack(spacing: 8) {
Image(systemName: "sportscourt.fill")
.font(.system(size: 20))

View File

@@ -57,8 +57,8 @@ extension ShareButton where Content == TripShareContent {
}
extension ShareButton where Content == ProgressShareContent {
init(progress: LeagueProgress, tripCount: Int = 0, username: String? = nil, style: ShareButtonStyle = .icon) {
self.content = ProgressShareContent(progress: progress, tripCount: tripCount, username: username)
init(progress: LeagueProgress, tripCount: Int = 0, style: ShareButtonStyle = .icon) {
self.content = ProgressShareContent(progress: progress, tripCount: tripCount)
self.style = style
}
}

View File

@@ -19,10 +19,6 @@ struct SharePreviewView<Content: ShareableContent>: View {
@State private var error: String?
@State private var showCopiedToast = false
// Progress-specific options
@State private var includeUsername = true
@State private var username = ""
init(content: Content) {
self.content = content
_selectedTheme = State(initialValue: ShareThemePreferences.theme(for: content.cardType))
@@ -38,11 +34,6 @@ struct SharePreviewView<Content: ShareableContent>: View {
// Theme selector
themeSelector
// Username toggle (progress cards only)
if content.cardType == .stadiumProgress {
usernameSection
}
// Action buttons
actionButtons
}
@@ -158,32 +149,6 @@ struct SharePreviewView<Content: ShareableContent>: View {
.buttonStyle(.plain)
}
// MARK: - Username Section
private var usernameSection: some View {
VStack(spacing: Theme.Spacing.sm) {
Toggle(isOn: $includeUsername) {
Text("Include username")
}
.onChange(of: includeUsername) { _, _ in
Task { await generatePreview() }
}
if includeUsername {
TextField("@username", text: $username)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.onChange(of: username) { _, _ in
Task { await generatePreview() }
}
}
}
.padding()
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
// MARK: - Action Buttons
private var actionButtons: some View {
@@ -204,39 +169,21 @@ struct SharePreviewView<Content: ShareableContent>: View {
}
.disabled(generatedImage == nil)
HStack(spacing: Theme.Spacing.sm) {
// Copy Image
Button {
copyImage()
} label: {
HStack {
Image(systemName: "doc.on.doc")
Text("Copy")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
// Copy Image
Button {
copyImage()
} label: {
HStack {
Image(systemName: "doc.on.doc")
Text("Copy to Clipboard")
}
.disabled(generatedImage == nil)
// More Options
Button {
showSystemShare()
} label: {
HStack {
Image(systemName: "ellipsis.circle")
Text("More")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
}
}
@@ -264,18 +211,7 @@ struct SharePreviewView<Content: ShareableContent>: View {
isGenerating = true
do {
// For progress content, we may need to inject username
if let progressContent = content as? ProgressShareContent {
// This is a workaround - ideally we'd have a more elegant solution
let modifiedContent = ProgressShareContent(
progress: progressContent.progress,
tripCount: progressContent.tripCount,
username: includeUsername ? (username.isEmpty ? nil : username) : nil
)
generatedImage = try await modifiedContent.render(theme: selectedTheme)
} else {
generatedImage = try await content.render(theme: selectedTheme)
}
generatedImage = try await content.render(theme: selectedTheme)
} catch {
self.error = error.localizedDescription
}

View File

@@ -157,29 +157,6 @@ struct HomeView: View {
.shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8)
}
// MARK: - Quick Actions
private var quickActions: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Quick Start")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
SportSelectorGrid { sport in
selectedSport = sport
showNewTrip = true
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, 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)
}
}
}
// MARK: - Suggested Trips
@ViewBuilder

View File

@@ -28,10 +28,6 @@ struct HomeContent_Classic: View {
.padding(.horizontal, Theme.Spacing.md)
.padding(.top, Theme.Spacing.sm)
// Quick Actions
quickActions
.padding(.horizontal, Theme.Spacing.md)
// Suggested Trips
suggestedTripsSection
.padding(.horizontal, Theme.Spacing.md)
@@ -97,28 +93,6 @@ struct HomeContent_Classic: View {
.shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8)
}
// MARK: - Quick Actions
private var quickActions: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Quick Start")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
SportSelectorGrid { _ in
showNewTrip = true
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, 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)
}
}
}
// MARK: - Suggested Trips
@ViewBuilder

View File

@@ -36,10 +36,6 @@ struct HomeContent_Spotify: View {
.padding(.horizontal, 16)
.padding(.top, 12)
// Quick actions
quickActions
.padding(.horizontal, 16)
// Your trips
if !savedTrips.isEmpty {
yourTripsSection
@@ -91,62 +87,6 @@ struct HomeContent_Spotify: View {
}
}
// MARK: - Quick Actions
private var quickActions: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
// New trip button
quickActionButton(title: "Plan Trip", icon: "plus.circle.fill", isPrimary: true) {
showNewTrip = true
}
// Saved trips button
if !savedTrips.isEmpty {
quickActionButton(title: "Your Trips", icon: "folder.fill", isPrimary: false) {
selectedTab = 2
}
}
// First saved trip quick access
if let firstTrip = savedTrips.first?.trip {
quickActionButton(title: firstTrip.name, icon: "play.circle.fill", isPrimary: false) {
// Could navigate to trip
}
}
// Refresh suggestions
quickActionButton(title: "Refresh", icon: "arrow.clockwise", isPrimary: false) {
Task {
await suggestedTripsGenerator.refreshTrips()
}
}
}
}
private func quickActionButton(title: String, icon: String, isPrimary: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 22))
.foregroundStyle(isPrimary ? spotifyGreen : textPrimary)
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(textPrimary)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(cardBg)
)
}
.buttonStyle(.plain)
}
// MARK: - Your Trips Section
private var yourTripsSection: some View {

View File

@@ -25,15 +25,19 @@ final class PollDetailViewModel {
return PollResults(poll: poll, votes: votes)
}
func checkIsOwner() async -> Bool {
guard let poll else { return false }
do {
let userId = try await pollService.getCurrentUserRecordID()
return poll.ownerId == userId
} catch {
return false
}
}
var isOwner: Bool {
get async {
guard let poll else { return false }
do {
let userId = try await pollService.getCurrentUserRecordID()
return poll.ownerId == userId
} catch {
return false
}
await checkIsOwner()
}
}
@@ -98,6 +102,32 @@ final class PollDetailViewModel {
isLoading = false
}
/// Load poll from an existing object (avoids CloudKit fetch delay)
func loadPoll(from existingPoll: TripPoll) async {
isLoading = true
error = nil
self.poll = existingPoll
do {
// Still fetch votes and subscription from CloudKit
async let votesTask = pollService.fetchVotes(forPollId: existingPoll.id)
async let myVoteTask = pollService.fetchMyVote(forPollId: existingPoll.id)
let (fetchedVotes, fetchedMyVote) = try await (votesTask, myVoteTask)
self.votes = fetchedVotes
self.myVote = fetchedMyVote
// Subscribe to vote updates
try? await pollService.subscribeToVoteUpdates(forPollId: existingPoll.id)
} catch {
// Votes fetch failed, but we have the poll - non-critical error
self.votes = []
}
isLoading = false
}
func refresh() async {
guard let poll else { return }

View File

@@ -18,15 +18,25 @@ struct PollDetailView: View {
let pollId: UUID?
let shareCode: String?
let initialPoll: TripPoll?
init(pollId: UUID) {
self.pollId = pollId
self.shareCode = nil
self.initialPoll = nil
}
init(shareCode: String) {
self.pollId = nil
self.shareCode = shareCode
self.initialPoll = nil
}
/// Initialize with a poll object directly (avoids fetch delay)
init(poll: TripPoll) {
self.pollId = poll.id
self.shareCode = nil
self.initialPoll = poll
}
var body: some View {
@@ -217,7 +227,10 @@ struct PollDetailView: View {
}
private func loadPoll() async {
if let pollId {
// If we have an initial poll object, use it directly to avoid CloudKit fetch delay
if let initialPoll {
await viewModel.loadPoll(from: initialPoll)
} else if let pollId {
await viewModel.loadPoll(byId: pollId)
} else if let shareCode {
await viewModel.loadPoll(byShareCode: shareCode)
@@ -291,6 +304,15 @@ private struct TripPreviewCard: View {
.font(.headline)
}
// Date range and duration
HStack {
Label(trip.formattedDateRange, systemImage: "calendar")
Spacer()
Label("\(trip.tripDuration) days", systemImage: "clock")
}
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Label("\(trip.stops.count) stops", systemImage: "mappin.and.ellipse")
Spacer()
@@ -303,7 +325,7 @@ private struct TripPreviewCard: View {
Text(trip.stops.map { $0.city }.joined(separator: ""))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(2)
}
.padding()
.background(Theme.cardBackground(colorScheme))

View File

@@ -11,6 +11,7 @@ struct PollsListView: View {
@Environment(\.colorScheme) private var colorScheme
@State private var polls: [TripPoll] = []
@State private var isLoading = false
@State private var hasLoadedInitially = false
@State private var error: PollError?
@State private var showJoinPoll = false
@State private var joinCode = ""
@@ -39,6 +40,8 @@ struct PollsListView: View {
await loadPolls()
}
.task {
guard !hasLoadedInitially else { return }
hasLoadedInitially = true
await loadPolls()
}
.alert("Join Poll", isPresented: $showJoinPoll) {
@@ -87,7 +90,7 @@ struct PollsListView: View {
}
.listStyle(.plain)
.navigationDestination(for: TripPoll.self) { poll in
PollDetailView(pollId: poll.id)
PollDetailView(poll: poll)
}
}

View File

@@ -492,11 +492,14 @@ struct AchievementDetailSheet: View {
.font(.title2)
.fontWeight(achievement.isEarned ? .bold : .regular)
.foregroundStyle(achievement.isEarned ? completedGold : Theme.textPrimary(colorScheme))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(achievement.definition.description)
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Category badge
HStack(spacing: 4) {

View File

@@ -269,7 +269,7 @@ struct GameRowView: View {
// Game info
HStack(spacing: 12) {
Label(game.game.gameTime, systemImage: "clock")
Label(game.localGameTimeShort, systemImage: "clock")
Label(game.stadium.name, systemImage: "building.2")
if let broadcast = game.game.broadcastInfo {

View File

@@ -1335,7 +1335,7 @@ struct GameCalendarRow: View {
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.xs) {
Label(game.game.gameTime, systemImage: "clock")
Label(game.localGameTimeShort, systemImage: "clock")
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
@@ -1838,9 +1838,7 @@ struct TripOptionsView: View {
Menu {
ForEach(TripPaceFilter.allCases) { pace in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
paceFilter = pace
}
paceFilter = pace
} label: {
Label(pace.rawValue, systemImage: pace.icon)
}
@@ -1865,7 +1863,6 @@ struct TripOptionsView: View {
Capsule()
.strokeBorder(paceFilter == .all ? Theme.textMuted(colorScheme).opacity(0.2) : Theme.warmOrange.opacity(0.3), lineWidth: 1)
)
.animation(nil, value: paceFilter)
}
}

View File

@@ -689,7 +689,7 @@ struct GameRow: View {
Spacer()
// Time
Text(game.game.gameTime)
Text(game.localGameTimeShort)
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}

View File

@@ -23,12 +23,31 @@ read docs/TEST_PLAN.md in full
question: do we need sync schedules anymore in settings
// things that are new
// new dev
- Notification reminders - "Your trip starts in 3 days"
// enhancements
- Dark/light mode toggle along with system
- Stadium notes - let users add personal notes/tips to stadiums they've visited
sharing needs to completely overhauled. should be able to share a trip summary, achievements, progress. social media first
- Achievements share says 2,026. Should be an option when share is hit of year or all time
- if viewing a single sport share should share details regarding that sport only
- need achievements for every supported league (how does this work with adding new sports in backed, might have to push with app update so if not achievements exist for that sport dont show it as an option)
- Share needs more styling, similar to onboarding
- Ready to plan your trip has a summary, missing details should be in red
// new ish to existing features
// 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.
- fucking game show at 7 am ... the fuck?
all all trips view when choosing "packed" "moderate" "relaxed" the capsule the option is in does a weird animation that looks off.
- Text on achievements is not wrapping and is being cutoff
- Remove username on share
- more on share doesnt do anything
- Created a poll and when I tap on it I get poll not found?
- group poll refreshed every time I go to screen, should update in bg and pull to refresh?
- User who made it should be able to delete it
- Trip details arent showing
- Home Screen quick start should be removed
- Today games arent highlighted in the schedule tab
- features trip showing both nba and nhl but only has a nhl game

134
docs/BUG_FIX_PLAN.md Normal file
View File

@@ -0,0 +1,134 @@
# Bug Fix Plan
**Created:** 2026-01-14
**Approach:** Batch by feature area, regression tests for each fix
**Total Bugs:** 12
---
## Batch 1: Sharing & Achievements (4 bugs)
**Files to modify:**
- `SportsTime/Export/Sharing/AchievementCardGenerator.swift`
- `SportsTime/Features/Progress/Views/AchievementsListView.swift`
- `SportsTime/Export/Sharing/ShareService.swift`
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|-----|-------------|----------------------|--------------|
| **1.1** | Achievement text not wrapping, being cut off | Missing `.lineLimit(nil)` or fixed frame width | Add proper text wrapping constraints to `AchievementCard` |
| **1.2** | Remove username on share | Username hardcoded in share card render | Remove username field from `AchievementCardGenerator` output |
| **1.3** | "More" button on share doesn't do anything | Button has no action handler | Implement or remove the "More" button |
| **1.4** | Achievements share says "2,026" instead of "2026" | Number formatting with thousands separator | Fix year formatting to not use locale grouping |
**Tests to add:**
- `test_AchievementCard_TextWrapsCorrectly_LongAchievementNames`
- `test_ShareCard_DoesNotIncludeUsername`
- `test_YearFormatting_NoThousandsSeparator`
---
## Batch 2: Polls (4 bugs)
**Files to modify:**
- `SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift`
- `SportsTime/Features/Polls/Views/PollsListView.swift`
- `SportsTime/Core/Services/PollService.swift`
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|-----|-------------|----------------------|--------------|
| **2.1** | Poll not found when tapping created poll | Share code case mismatch or CloudKit sync delay | Verify case handling; add local cache lookup before CloudKit |
| **2.2** | Poll refreshes every time screen appears | Missing cache, always fetching from CloudKit | Add local state caching, only fetch on pull-to-refresh |
| **2.3** | Creator cannot delete their poll | Deletion permission check failing or UI missing | Verify `notPollOwner` check; add delete button for owner |
| **2.4** | Trip details not showing in poll | Poll options not loading trip data | Check if trip reference is being dereferenced correctly |
**Tests to add:**
- `test_Poll_FoundByShareCode_CaseInsensitive`
- `test_PollsList_UsesLocalCache_NotRefreshOnAppear`
- `test_PollOwner_CanDeleteOwnPoll`
- `test_Poll_ShowsTripDetails_WhenLinked`
---
## Batch 3: Schedule & Games (3 bugs)
**Files to modify:**
- `SportsTime/Features/Schedule/Views/ScheduleListView.swift`
- `SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift`
- `SportsTime/Features/Home/Views/Variants/Classic/HomeContent_Classic.swift`
- Possibly: Data layer if timezone issue
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|-----|-------------|----------------------|--------------|
| **3.1** | Games showing at 7 AM (incorrect time) | Timezone conversion issue (UTC displayed as local) | Investigate game time storage; fix timezone handling |
| **3.2** | Today's games not highlighted | `isToday` cache not reactive or comparison bug | Check date comparison logic; ensure proper calendar usage |
| **3.3** | Featured trip shows NBA+NHL but only has NHL game | Filter logic bug in `SuggestedTripsGenerator` | Fix sport filtering when generating featured trips |
**Tests to add:**
- `test_GameTime_DisplaysInLocalTimezone`
- `test_ScheduleList_HighlightsTodaysGames`
- `test_FeaturedTrip_OnlyShowsSportsWithGames`
---
## Batch 4: Home Screen (1 bug)
**Files to modify:**
- `SportsTime/Features/Home/Views/Variants/Classic/HomeContent_Classic.swift`
- Possibly all 18+ design variants in `Features/Home/Views/Variants/`
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|-----|-------------|----------------------|--------------|
| **4.1** | Remove Home Screen quick start | Feature no longer wanted | Remove `quickActions` section from all home variants |
**Tests to add:**
- `test_HomeScreen_NoQuickStartSection`
**Note:** Need to check all 18+ design variants. May need to grep for `quickActions` or `SportSelectorGrid` across all variant files.
---
## Batch 5: Trips View (1 bug)
**Files to modify:**
- `SportsTime/Features/Trip/Views/TripCreationView.swift` (or wherever pace selector lives)
- Possibly: A filter view in trips list
| Bug | Description | Root Cause Hypothesis | Fix Approach |
|-----|-------------|----------------------|--------------|
| **5.1** | Pace capsule animation looks off when selecting packed/moderate/relaxed | Animation timing or layout issue with selection state | Investigate capsule animation; fix transition |
**Note from exploration:** The pace selector UI may be in a trips filtering view (line 1521 shows `TripPaceFilter`), not trip creation. Need to locate the exact view with the animation bug.
**Tests to add:**
- UI test: `test_PaceSelector_AnimatesSmoothlOnSelection`
---
## Execution Order
1. **Batch 1 (Sharing)** - Most visible to users, affects social features
2. **Batch 2 (Polls)** - Existing functionality broke, needs regression fix
3. **Batch 3 (Schedule)** - Core app functionality
4. **Batch 4 (Home)** - Simple removal
5. **Batch 5 (Trips)** - Minor UI polish
---
## Investigation Needed Before Fixes
| Item | Question | How to Investigate |
|------|----------|-------------------|
| 7 AM games | Is this timezone or bad data? | Check a specific game's stored `dateTime` vs expected local time |
| Poll not found | Is it case sensitivity or sync timing? | Test with uppercase/lowercase share codes; check CloudKit logs |
| More button | What should it do, or should it be removed? | Ask user for intended behavior |
| Quick start | Remove from all 18 variants or just Classic? | Confirm scope with user |
---
## Decisions Made
| Question | Decision |
|----------|----------|
| "More" button on share | **Remove it** - button serves no purpose |
| Quick start removal | **All 18+ variants** - for consistency |
| Poll trip details | **Full summary** - cities, games, dates, duration |