feat: add planning tips and grouped trip options sorting

- Add PlanningTips data model with 105 tips across 7 categories
- Wire random tips into HomeView (3 tips per session)
- Add TripOptionsGrouper for grouping by city/game count and mileage
- Update TripOptionsView with sectioned display when sorting
- Recommended and Best Efficiency remain flat lists

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-12 21:49:04 -06:00
parent 89167c01d7
commit 0524284ab8
5 changed files with 389 additions and 13 deletions

View File

@@ -1567,6 +1567,58 @@ enum CitiesFilter: Int, CaseIterable, Identifiable {
}
}
// 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)
}
}
}
struct TripOptionsView: View {
let options: [ItineraryOption]
let games: [String: RichGame]
@@ -1641,6 +1693,29 @@ struct TripOptionsView: View {
return Double(option.totalGames) / Double(days)
}
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)
}
}
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
@@ -1660,21 +1735,43 @@ struct TripOptionsView: View {
filtersSection
.padding(.horizontal, Theme.Spacing.md)
// Options list
// Options list (grouped when applicable)
if filteredAndSortedOptions.isEmpty {
emptyFilterState
.padding(.top, Theme.Spacing.xl)
} else {
ForEach(filteredAndSortedOptions) { option in
TripOptionCard(
option: option,
games: games,
onSelect: {
selectedTrip = convertToTrip(option)
showTripDetail = true
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)
}
)
.padding(.horizontal, 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)
}
}
}
}
}