fix: 14 audit fixes — concurrency, memory, performance, accessibility
Second audit round addressing data races, task stacking, unbounded caches, and VoiceOver gaps across 7 files. Concurrency: - Move NSItemProvider @State access into MainActor block (3 drop handlers) - Cancel stale ScheduleViewModel tasks on rapid filter changes Memory: - Replace unbounded image dict with LRUCache(countLimit: 50) - Replace demo-mode asyncAfter with cancellable Task Performance: - Wrap debug NBA print() in #if DEBUG - Cache visitsById via @State + onChange instead of per-render computed - Pre-compute sportProgressFractions in ProgressViewModel - Replace allGames computed property with hasGames bool check - Cache sortedTrips via @State + onChange in SavedTripsListView Accessibility: - Add combined VoiceOver label to progress ring - Combine away/home team text into single readable phrase - Hide decorative StadiumDetailSheet icon from VoiceOver - Add explicit accessibilityLabel to SportFilterChip - Add combined accessibilityLabel to GameRowView Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import LRUCache
|
||||||
|
|
||||||
actor RemoteImageService {
|
actor RemoteImageService {
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ actor RemoteImageService {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
private let urlSession: URLSession
|
private let urlSession: URLSession
|
||||||
private var imageCache: [URL: UIImage] = [:]
|
private let imageCache = LRUCache<URL, UIImage>(countLimit: 50)
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ actor RemoteImageService {
|
|||||||
/// Fetch a single image from URL
|
/// Fetch a single image from URL
|
||||||
func fetchImage(from url: URL) async throws -> UIImage {
|
func fetchImage(from url: URL) async throws -> UIImage {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if let cached = imageCache[url] {
|
if let cached = imageCache.value(forKey: url) {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ actor RemoteImageService {
|
|||||||
let scaledImage = scaleImage(image, maxWidth: 400)
|
let scaledImage = scaleImage(image, maxWidth: 400)
|
||||||
|
|
||||||
// Cache it
|
// Cache it
|
||||||
imageCache[url] = scaledImage
|
imageCache.setValue(scaledImage, forKey: url)
|
||||||
|
|
||||||
return scaledImage
|
return scaledImage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,10 +479,7 @@ struct SavedTripsListView: View {
|
|||||||
@State private var showDebugPoll = false
|
@State private var showDebugPoll = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Trips sorted by most cities (stops) first
|
@State private var sortedTrips: [SavedTrip] = []
|
||||||
private var sortedTrips: [SavedTrip] {
|
|
||||||
trips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trips as domain objects for poll creation
|
/// Trips as domain objects for poll creation
|
||||||
private var tripsForPollCreation: [Trip] {
|
private var tripsForPollCreation: [Trip] {
|
||||||
@@ -501,6 +498,9 @@ struct SavedTripsListView: View {
|
|||||||
.padding(Theme.Spacing.md)
|
.padding(Theme.Spacing.md)
|
||||||
}
|
}
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
|
.onChange(of: trips, initial: true) { _, newTrips in
|
||||||
|
sortedTrips = newTrips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) }
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
guard !hasLoadedPolls else { return }
|
guard !hasLoadedPolls else { return }
|
||||||
hasLoadedPolls = true
|
hasLoadedPolls = true
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ final class ProgressViewModel {
|
|||||||
/// Unvisited stadiums for the selected sport
|
/// Unvisited stadiums for the selected sport
|
||||||
private(set) var unvisitedStadiums: [Stadium] = []
|
private(set) var unvisitedStadiums: [Stadium] = []
|
||||||
|
|
||||||
|
/// Pre-computed progress fractions per sport (avoids filtering visits per sport per render)
|
||||||
|
private(set) var sportProgressFractions: [Sport: Double] = [:]
|
||||||
|
|
||||||
/// Stadiums for the selected sport
|
/// Stadiums for the selected sport
|
||||||
var sportStadiums: [Stadium] {
|
var sportStadiums: [Stadium] {
|
||||||
stadiums.filter { $0.sport == selectedSport }
|
stadiums.filter { $0.sport == selectedSport }
|
||||||
@@ -200,6 +203,19 @@ final class ProgressViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
stadiumVisitStatus = statusMap
|
stadiumVisitStatus = statusMap
|
||||||
|
|
||||||
|
// Pre-compute sport progress fractions for SportSelectorGrid
|
||||||
|
var sportCounts: [Sport: Int] = [:]
|
||||||
|
for visit in visits {
|
||||||
|
if let sport = visit.sportEnum {
|
||||||
|
sportCounts[sport, default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sportProgressFractions = Dictionary(uniqueKeysWithValues: Sport.supported.map { sport in
|
||||||
|
let total = LeagueStructure.stadiumCount(for: sport)
|
||||||
|
let visited = min(sportCounts[sport] ?? 0, total)
|
||||||
|
return (sport, total > 0 ? Double(visited) / Double(total) : 0)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Progress Card Generation
|
// MARK: - Progress Card Generation
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ struct ProgressTabView: View {
|
|||||||
@State private var selectedVisitId: UUID?
|
@State private var selectedVisitId: UUID?
|
||||||
|
|
||||||
@Query private var visits: [StadiumVisit]
|
@Query private var visits: [StadiumVisit]
|
||||||
|
@State private var visitsById: [UUID: StadiumVisit] = [:]
|
||||||
/// O(1) lookup for visits by ID (built from @Query results)
|
|
||||||
private var visitsById: [UUID: StadiumVisit] {
|
|
||||||
Dictionary(uniqueKeysWithValues: visits.map { ($0.id, $0) })
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -66,6 +62,9 @@ struct ProgressTabView: View {
|
|||||||
.accessibilityLabel("Add stadium visit")
|
.accessibilityLabel("Add stadium visit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: visits, initial: true) { _, newVisits in
|
||||||
|
visitsById = Dictionary(uniqueKeysWithValues: newVisits.map { ($0.id, $0) })
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
viewModel.configure(with: modelContext.container)
|
viewModel.configure(with: modelContext.container)
|
||||||
await viewModel.loadData()
|
await viewModel.loadData()
|
||||||
@@ -153,7 +152,7 @@ struct ProgressTabView: View {
|
|||||||
SportProgressButton(
|
SportProgressButton(
|
||||||
sport: sport,
|
sport: sport,
|
||||||
isSelected: viewModel.selectedSport == sport,
|
isSelected: viewModel.selectedSport == sport,
|
||||||
progress: progressForSport(sport)
|
progress: viewModel.sportProgressFractions[sport] ?? 0
|
||||||
) {
|
) {
|
||||||
Theme.Animation.withMotion(Theme.Animation.spring) {
|
Theme.Animation.withMotion(Theme.Animation.spring) {
|
||||||
viewModel.selectSport(sport)
|
viewModel.selectSport(sport)
|
||||||
@@ -162,12 +161,6 @@ struct ProgressTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func progressForSport(_ sport: Sport) -> Double {
|
|
||||||
let visitedCount = viewModel.visits.filter { $0.sportEnum == sport }.count
|
|
||||||
let total = LeagueStructure.stadiumCount(for: sport)
|
|
||||||
guard total > 0 else { return 0 }
|
|
||||||
return Double(min(visitedCount, total)) / Double(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Progress Summary Card
|
// MARK: - Progress Summary Card
|
||||||
|
|
||||||
@@ -204,6 +197,8 @@ struct ProgressTabView: View {
|
|||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("\(progress.visitedStadiums) of \(progress.totalStadiums) stadiums visited, \(Int(progress.completionPercentage)) percent complete")
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||||
Text(viewModel.selectedSport.displayName)
|
Text(viewModel.selectedSport.displayName)
|
||||||
@@ -540,13 +535,10 @@ struct RecentVisitRow: View {
|
|||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
// Date, Away @ Home on one line, left aligned
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(visit.shortDateDescription)
|
Text(visit.shortDateDescription)
|
||||||
if let away = visit.awayTeamName, let home = visit.homeTeamName {
|
if let away = visit.awayTeamName, let home = visit.homeTeamName {
|
||||||
Text(away)
|
Text("\(away) at \(home)")
|
||||||
Text("@")
|
|
||||||
Text(home)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -596,6 +588,7 @@ struct StadiumDetailSheet: View {
|
|||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
|
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
|
||||||
}
|
}
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
Text(stadium.name)
|
Text(stadium.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ final class ScheduleViewModel {
|
|||||||
private(set) var errorMessage: String?
|
private(set) var errorMessage: String?
|
||||||
|
|
||||||
private let dataProvider = AppDataProvider.shared
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
private var loadTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Pre-computed Groupings (avoid computed property overhead)
|
// MARK: - Pre-computed Groupings (avoid computed property overhead)
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ final class ScheduleViewModel {
|
|||||||
logger.info("📅 \(sport.rawValue): \(count) games")
|
logger.info("📅 \(sport.rawValue): \(count) games")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
// Debug: Print all NBA games
|
// Debug: Print all NBA games
|
||||||
let nbaGames = games.filter { $0.game.sport == .nba }
|
let nbaGames = games.filter { $0.game.sport == .nba }
|
||||||
print("🏀 [DEBUG] All NBA games in schedule (\(nbaGames.count) total):")
|
print("🏀 [DEBUG] All NBA games in schedule (\(nbaGames.count) total):")
|
||||||
@@ -124,6 +126,7 @@ final class ScheduleViewModel {
|
|||||||
let dateStr = game.game.dateTime.gameDateTimeString(in: game.stadium.timeZone)
|
let dateStr = game.game.dateTime.gameDateTimeString(in: game.stadium.timeZone)
|
||||||
print("🏀 \(dateStr): \(game.awayTeam.name) @ \(game.homeTeam.name) (\(game.game.id))")
|
print("🏀 \(dateStr): \(game.awayTeam.name) @ \(game.homeTeam.name) (\(game.game.id))")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
} catch let cloudKitError as CloudKitError {
|
} catch let cloudKitError as CloudKitError {
|
||||||
self.error = cloudKitError
|
self.error = cloudKitError
|
||||||
@@ -151,9 +154,8 @@ final class ScheduleViewModel {
|
|||||||
selectedSports.insert(sport)
|
selectedSports.insert(sport)
|
||||||
}
|
}
|
||||||
AnalyticsManager.shared.track(.scheduleFiltered(sport: sport.rawValue, dateRange: "\(startDate.formatted(.dateTime.month().day())) - \(endDate.formatted(.dateTime.month().day()))"))
|
AnalyticsManager.shared.track(.scheduleFiltered(sport: sport.rawValue, dateRange: "\(startDate.formatted(.dateTime.month().day())) - \(endDate.formatted(.dateTime.month().day()))"))
|
||||||
Task {
|
loadTask?.cancel()
|
||||||
await loadGames()
|
loadTask = Task { await loadGames() }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetFilters() {
|
func resetFilters() {
|
||||||
@@ -161,17 +163,15 @@ final class ScheduleViewModel {
|
|||||||
searchText = ""
|
searchText = ""
|
||||||
startDate = Date()
|
startDate = Date()
|
||||||
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
||||||
Task {
|
loadTask?.cancel()
|
||||||
await loadGames()
|
loadTask = Task { await loadGames() }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDateRange(start: Date, end: Date) {
|
func updateDateRange(start: Date, end: Date) {
|
||||||
startDate = start
|
startDate = start
|
||||||
endDate = end
|
endDate = end
|
||||||
Task {
|
loadTask?.cancel()
|
||||||
await loadGames()
|
loadTask = Task { await loadGames() }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filtering & Grouping (pre-computed, not computed properties)
|
// MARK: - Filtering & Grouping (pre-computed, not computed properties)
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ struct ScheduleListView: View {
|
|||||||
@State private var showDatePicker = false
|
@State private var showDatePicker = false
|
||||||
@State private var showDiagnostics = false
|
@State private var showDiagnostics = false
|
||||||
|
|
||||||
private var allGames: [RichGame] {
|
private var hasGames: Bool {
|
||||||
viewModel.gamesBySport.flatMap(\.games)
|
!viewModel.gamesBySport.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isLoading && allGames.isEmpty {
|
if viewModel.isLoading && !hasGames {
|
||||||
loadingView
|
loadingView
|
||||||
} else if let errorMessage = viewModel.errorMessage {
|
} else if let errorMessage = viewModel.errorMessage {
|
||||||
errorView(message: errorMessage)
|
errorView(message: errorMessage)
|
||||||
} else if allGames.isEmpty {
|
} else if !hasGames {
|
||||||
emptyView
|
emptyView
|
||||||
} else {
|
} else {
|
||||||
gamesList
|
gamesList
|
||||||
@@ -224,6 +224,7 @@ struct SportFilterChip: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityIdentifier("schedule.sport.\(sport.rawValue.lowercased())")
|
.accessibilityIdentifier("schedule.sport.\(sport.rawValue.lowercased())")
|
||||||
|
.accessibilityLabel(sport.rawValue)
|
||||||
.accessibilityValue(isSelected ? "Selected" : "Not selected")
|
.accessibilityValue(isSelected ? "Selected" : "Not selected")
|
||||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||||
}
|
}
|
||||||
@@ -313,6 +314,18 @@ struct GameRowView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
|
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(gameAccessibilityLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gameAccessibilityLabel: String {
|
||||||
|
var parts = ["\(game.awayTeam.name) at \(game.homeTeam.name)"]
|
||||||
|
parts.append(game.stadium.name)
|
||||||
|
parts.append(game.localGameTimeShort)
|
||||||
|
if showDate {
|
||||||
|
parts.append(Self.dateFormatter.string(from: game.game.dateTime))
|
||||||
|
}
|
||||||
|
return parts.joined(separator: ", ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ struct TripDetailView: View {
|
|||||||
@State private var loadedGames: [String: RichGame] = [:]
|
@State private var loadedGames: [String: RichGame] = [:]
|
||||||
@State private var isLoadingGames = false
|
@State private var isLoadingGames = false
|
||||||
@State private var hasAppliedDemoSelection = false
|
@State private var hasAppliedDemoSelection = false
|
||||||
|
@State private var demoSaveTask: Task<Void, Never>?
|
||||||
|
|
||||||
// Itinerary items state
|
// Itinerary items state
|
||||||
@State private var itineraryItems: [ItineraryItem] = []
|
@State private var itineraryItems: [ItineraryItem] = []
|
||||||
@@ -122,10 +123,10 @@ struct TripDetailView: View {
|
|||||||
// Demo mode: auto-favorite the trip
|
// Demo mode: auto-favorite the trip
|
||||||
if isDemoMode && !hasAppliedDemoSelection && !isSaved {
|
if isDemoMode && !hasAppliedDemoSelection && !isSaved {
|
||||||
hasAppliedDemoSelection = true
|
hasAppliedDemoSelection = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
|
demoSaveTask = Task {
|
||||||
if !isSaved {
|
try? await Task.sleep(for: .seconds(DemoConfig.selectionDelay + 0.5))
|
||||||
saveTrip()
|
guard !Task.isCancelled, !isSaved else { return }
|
||||||
}
|
saveTrip()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,10 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
recomputeSections()
|
recomputeSections()
|
||||||
}
|
}
|
||||||
.onDisappear { subscriptionCancellable?.cancel() }
|
.onDisappear {
|
||||||
|
subscriptionCancellable?.cancel()
|
||||||
|
demoSaveTask?.cancel()
|
||||||
|
}
|
||||||
.onChange(of: itineraryItems) { _, newItems in
|
.onChange(of: itineraryItems) { _, newItems in
|
||||||
handleItineraryItemsChange(newItems)
|
handleItineraryItemsChange(newItems)
|
||||||
recomputeSections()
|
recomputeSections()
|
||||||
@@ -715,10 +719,10 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||||
guard let droppedId = item as? String,
|
guard let droppedId = item as? String,
|
||||||
let itemId = UUID(uuidString: droppedId),
|
let itemId = UUID(uuidString: droppedId) else { return }
|
||||||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
||||||
let day = self.findDayForTravelSegment(segment)
|
let day = self.findDayForTravelSegment(segment)
|
||||||
// Place at beginning of day (sortOrder before existing items)
|
// Place at beginning of day (sortOrder before existing items)
|
||||||
let minSortOrder = self.itineraryItems
|
let minSortOrder = self.itineraryItems
|
||||||
@@ -743,11 +747,11 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||||
guard let droppedId = item as? String,
|
guard let droppedId = item as? String,
|
||||||
let itemId = UUID(uuidString: droppedId),
|
let itemId = UUID(uuidString: droppedId) else { return }
|
||||||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }),
|
|
||||||
droppedItem.id != targetItem.id else { return }
|
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }),
|
||||||
|
droppedItem.id != targetItem.id else { return }
|
||||||
// Place before target item using midpoint insertion
|
// Place before target item using midpoint insertion
|
||||||
let itemsInDay = self.itineraryItems.filter { $0.day == targetItem.day && $0.id != droppedItem.id }
|
let itemsInDay = self.itineraryItems.filter { $0.day == targetItem.day && $0.id != droppedItem.id }
|
||||||
.sorted { $0.sortOrder < $1.sortOrder }
|
.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
@@ -772,10 +776,10 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||||
guard let droppedId = item as? String,
|
guard let droppedId = item as? String,
|
||||||
let itemId = UUID(uuidString: droppedId),
|
let itemId = UUID(uuidString: droppedId) else { return }
|
||||||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
||||||
// Calculate sortOrder: append at end of day's items
|
// Calculate sortOrder: append at end of day's items
|
||||||
let maxSortOrder = self.itineraryItems
|
let maxSortOrder = self.itineraryItems
|
||||||
.filter { $0.day == day && $0.id != droppedItem.id }
|
.filter { $0.day == day && $0.id != droppedItem.id }
|
||||||
|
|||||||
Reference in New Issue
Block a user