fyliu Claude Opus 4.6 commited on
Commit
ab345fa
·
1 Parent(s): 8c371f4

Add flight amenities: legroom, WiFi, power/USB, video per segment

Browse files

Generate amenities deterministically based on aircraft type, cabin class,
and flight distance. Shown in Google Flights-style right-side panel in
the expanded flight card view, with icons and strikethrough for unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

backend/config.py CHANGED
@@ -118,6 +118,51 @@ BEST_WEIGHT_PRICE = 0.45
118
  BEST_WEIGHT_DURATION = 0.35
119
  BEST_WEIGHT_STOPS = 0.20
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  # Search limits
122
  MAX_RESULTS = 200
123
  MAX_AUTOCOMPLETE_RESULTS = 10
 
118
  BEST_WEIGHT_DURATION = 0.35
119
  BEST_WEIGHT_STOPS = 0.20
120
 
121
+ # Amenity generation rules
122
+ # Legroom ranges (inches) by cabin class
123
+ LEGROOM_RANGES = {
124
+ "economy": (29, 32),
125
+ "premium_economy": (34, 38),
126
+ "business": (38, 78),
127
+ "first": (78, 86),
128
+ }
129
+
130
+ # WiFi probability by aircraft family (widebody long-haul more likely)
131
+ WIFI_AIRCRAFT = {
132
+ "787-8": 0.95, "787-9": 0.95, "787-10": 0.95,
133
+ "A350-900": 0.92, "A350-1000": 0.92,
134
+ "A330-300": 0.70, "777-300ER": 0.85,
135
+ "A380": 0.80,
136
+ "A321neo LR": 0.80, "757-200": 0.50, "767-300ER": 0.60,
137
+ "A320": 0.55, "A321": 0.60, "737-800": 0.50, "737 MAX 8": 0.75,
138
+ "E190": 0.25, "E175": 0.20, "CRJ-900": 0.15,
139
+ }
140
+
141
+ # Power/USB probability by aircraft family
142
+ POWER_AIRCRAFT = {
143
+ "787-8": 0.95, "787-9": 0.95, "787-10": 0.95,
144
+ "A350-900": 0.95, "A350-1000": 0.95,
145
+ "A330-300": 0.75, "777-300ER": 0.85,
146
+ "A380": 0.90,
147
+ "A321neo LR": 0.80, "757-200": 0.40, "767-300ER": 0.55,
148
+ "A320": 0.45, "A321": 0.50, "737-800": 0.40, "737 MAX 8": 0.70,
149
+ "E190": 0.15, "E175": 0.10, "CRJ-900": 0.05,
150
+ }
151
+
152
+ # Video probability (seatback IFE) — mostly widebody long-haul
153
+ VIDEO_AIRCRAFT = {
154
+ "787-8": 0.90, "787-9": 0.92, "787-10": 0.92,
155
+ "A350-900": 0.93, "A350-1000": 0.93,
156
+ "A330-300": 0.80, "777-300ER": 0.90,
157
+ "A380": 0.95,
158
+ "A321neo LR": 0.40, "757-200": 0.25, "767-300ER": 0.60,
159
+ "A320": 0.10, "A321": 0.12, "737-800": 0.08, "737 MAX 8": 0.15,
160
+ "E190": 0.0, "E175": 0.0, "CRJ-900": 0.0,
161
+ }
162
+
163
+ # Business/first class always get power and higher WiFi/video odds
164
+ PREMIUM_CLASS_AMENITY_BOOST = 0.30 # +30% probability for business/first
165
+
166
  # Search limits
167
  MAX_RESULTS = 200
168
  MAX_AUTOCOMPLETE_RESULTS = 10
backend/flight_generator.py CHANGED
@@ -13,12 +13,17 @@ from .config import (
13
  DEPARTURE_HOUR_MAX,
14
  DEPARTURE_HOUR_MIN,
15
  EMISSIONS_KG_PER_KM,
 
16
  MAX_FLIGHTS_MULTI_CARRIER,
17
  MAX_FLIGHTS_SINGLE_CARRIER,
18
  MAX_LAYOVER_MINUTES,
19
  MIN_FLIGHTS_PER_DAY,
20
  MIN_LAYOVER_MINUTES,
 
 
21
  SAME_AIRLINE_CONNECTION_DISCOUNT,
 
 
22
  )
23
  from .data_loader import Route, RouteGraph
24
  from .models import CabinClass, FlightOffer, FlightSegment
@@ -38,6 +43,58 @@ def _make_flight_number(carrier_iata: str, rng: random.Random) -> str:
38
  return f"{carrier_iata}{rng.randint(100, 9999)}"
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def _get_timezone(graph: RouteGraph, iata: str) -> ZoneInfo:
42
  airport = graph.airports.get(iata)
43
  if airport and airport.timezone:
@@ -129,11 +186,14 @@ def _generate_direct_flights(
129
 
130
  flight_id = f"{origin}{destination}{departure_date.isoformat()}{dep_minutes}{carrier['iata']}"
131
 
 
 
 
132
  segment = FlightSegment(
133
  airline_code=carrier["iata"],
134
  airline_name=carrier["name"],
135
  flight_number=_make_flight_number(carrier["iata"], rng),
136
- aircraft=_pick_aircraft(leg.distance_km, rng),
137
  origin=origin,
138
  origin_city=origin_airport.city_name,
139
  destination=destination,
@@ -141,6 +201,7 @@ def _generate_direct_flights(
141
  departure=departure_dt,
142
  arrival=arrival_dt,
143
  duration_minutes=leg.duration_min,
 
144
  )
145
 
146
  emissions = int(leg.distance_km * EMISSIONS_KG_PER_KM.get(cabin_class.value, 0.09))
@@ -221,11 +282,14 @@ def _generate_connecting_flights(
221
  leg_price *= CONNECTING_BASE_DISCOUNT
222
  total_price += leg_price
223
 
 
 
 
224
  segments.append(FlightSegment(
225
  airline_code=carrier["iata"],
226
  airline_name=carrier["name"],
227
  flight_number=_make_flight_number(carrier["iata"], rng),
228
- aircraft=_pick_aircraft(leg.distance_km, rng),
229
  origin=leg_origin,
230
  origin_city=origin_ap.city_name,
231
  destination=leg_dest,
@@ -233,6 +297,7 @@ def _generate_connecting_flights(
233
  departure=departure_dt,
234
  arrival=arrival_dt,
235
  duration_minutes=leg.duration_min,
 
236
  ))
237
 
238
  # Add layover time for next leg
 
13
  DEPARTURE_HOUR_MAX,
14
  DEPARTURE_HOUR_MIN,
15
  EMISSIONS_KG_PER_KM,
16
+ LEGROOM_RANGES,
17
  MAX_FLIGHTS_MULTI_CARRIER,
18
  MAX_FLIGHTS_SINGLE_CARRIER,
19
  MAX_LAYOVER_MINUTES,
20
  MIN_FLIGHTS_PER_DAY,
21
  MIN_LAYOVER_MINUTES,
22
+ POWER_AIRCRAFT,
23
+ PREMIUM_CLASS_AMENITY_BOOST,
24
  SAME_AIRLINE_CONNECTION_DISCOUNT,
25
+ VIDEO_AIRCRAFT,
26
+ WIFI_AIRCRAFT,
27
  )
28
  from .data_loader import Route, RouteGraph
29
  from .models import CabinClass, FlightOffer, FlightSegment
 
43
  return f"{carrier_iata}{rng.randint(100, 9999)}"
44
 
45
 
46
+ def _generate_amenities(
47
+ aircraft: str, cabin_class: CabinClass, distance_km: int, rng: random.Random,
48
+ ) -> dict:
49
+ """Generate seat amenities based on aircraft, cabin class, and distance."""
50
+ cls = cabin_class.value
51
+ lo, hi = LEGROOM_RANGES.get(cls, (29, 32))
52
+ legroom = rng.randint(lo, hi)
53
+
54
+ is_premium = cls in ("business", "first")
55
+ boost = PREMIUM_CLASS_AMENITY_BOOST if is_premium else 0.0
56
+
57
+ # WiFi
58
+ wifi_prob = min(1.0, WIFI_AIRCRAFT.get(aircraft, 0.3) + boost)
59
+ has_wifi = rng.random() < wifi_prob
60
+ wifi_type = None
61
+ if has_wifi:
62
+ # Free WiFi more common on premium classes and newer aircraft
63
+ free_prob = 0.7 if is_premium else (0.3 if aircraft.startswith(("787", "A35")) else 0.12)
64
+ wifi_type = "Free Wi-Fi" if rng.random() < free_prob else "Wi-Fi for a fee"
65
+
66
+ # Power & USB
67
+ power_prob = min(1.0, POWER_AIRCRAFT.get(aircraft, 0.2) + boost)
68
+ has_power = rng.random() < power_prob
69
+ # USB slightly more common than AC power
70
+ has_usb = has_power or rng.random() < min(1.0, power_prob + 0.15)
71
+
72
+ # Video/IFE
73
+ video_prob = min(1.0, VIDEO_AIRCRAFT.get(aircraft, 0.05) + boost)
74
+ # Long-haul (>4000km) gets a boost
75
+ if distance_km > 4000:
76
+ video_prob = min(1.0, video_prob + 0.15)
77
+ has_video = rng.random() < video_prob
78
+ video_type = None
79
+ if has_video:
80
+ if aircraft in ("A380", "777-300ER", "787-9", "787-10", "A350-900", "A350-1000"):
81
+ video_type = "On-demand video"
82
+ elif distance_km > 3000:
83
+ video_type = "On-demand video" if rng.random() < 0.7 else "Seatback screen"
84
+ else:
85
+ video_type = "Live TV" if rng.random() < 0.4 else "Seatback screen"
86
+
87
+ return {
88
+ "legroom_inches": legroom,
89
+ "has_wifi": has_wifi,
90
+ "wifi_type": wifi_type,
91
+ "has_power": has_power,
92
+ "has_usb": has_usb,
93
+ "has_video": has_video,
94
+ "video_type": video_type,
95
+ }
96
+
97
+
98
  def _get_timezone(graph: RouteGraph, iata: str) -> ZoneInfo:
99
  airport = graph.airports.get(iata)
100
  if airport and airport.timezone:
 
186
 
187
  flight_id = f"{origin}{destination}{departure_date.isoformat()}{dep_minutes}{carrier['iata']}"
188
 
189
+ aircraft = _pick_aircraft(leg.distance_km, rng)
190
+ amenities = _generate_amenities(aircraft, cabin_class, leg.distance_km, rng)
191
+
192
  segment = FlightSegment(
193
  airline_code=carrier["iata"],
194
  airline_name=carrier["name"],
195
  flight_number=_make_flight_number(carrier["iata"], rng),
196
+ aircraft=aircraft,
197
  origin=origin,
198
  origin_city=origin_airport.city_name,
199
  destination=destination,
 
201
  departure=departure_dt,
202
  arrival=arrival_dt,
203
  duration_minutes=leg.duration_min,
204
+ **amenities,
205
  )
206
 
207
  emissions = int(leg.distance_km * EMISSIONS_KG_PER_KM.get(cabin_class.value, 0.09))
 
282
  leg_price *= CONNECTING_BASE_DISCOUNT
283
  total_price += leg_price
284
 
285
+ aircraft = _pick_aircraft(leg.distance_km, rng)
286
+ amenities = _generate_amenities(aircraft, cabin_class, leg.distance_km, rng)
287
+
288
  segments.append(FlightSegment(
289
  airline_code=carrier["iata"],
290
  airline_name=carrier["name"],
291
  flight_number=_make_flight_number(carrier["iata"], rng),
292
+ aircraft=aircraft,
293
  origin=leg_origin,
294
  origin_city=origin_ap.city_name,
295
  destination=leg_dest,
 
297
  departure=departure_dt,
298
  arrival=arrival_dt,
299
  duration_minutes=leg.duration_min,
300
+ **amenities,
301
  ))
302
 
303
  # Add layover time for next leg
backend/models.py CHANGED
@@ -58,6 +58,14 @@ class FlightSegment(BaseModel):
58
  departure: datetime
59
  arrival: datetime
60
  duration_minutes: int
 
 
 
 
 
 
 
 
61
 
62
 
63
  # --- Flight offer (may have multiple segments) ---
 
58
  departure: datetime
59
  arrival: datetime
60
  duration_minutes: int
61
+ # Amenities
62
+ legroom_inches: int = 31
63
+ has_wifi: bool = False
64
+ wifi_type: Optional[str] = None # "Free Wi-Fi", "Wi-Fi for a fee"
65
+ has_power: bool = False
66
+ has_usb: bool = False
67
+ has_video: bool = False
68
+ video_type: Optional[str] = None # "On-demand video", "Live TV", "Seatback screen"
69
 
70
 
71
  # --- Flight offer (may have multiple segments) ---
frontend/src/api/types.ts CHANGED
@@ -23,6 +23,14 @@ export interface FlightSegment {
23
  departure: string;
24
  arrival: string;
25
  duration_minutes: number;
 
 
 
 
 
 
 
 
26
  }
27
 
28
  export interface FlightOffer {
 
23
  departure: string;
24
  arrival: string;
25
  duration_minutes: number;
26
+ // Amenities
27
+ legroom_inches: number;
28
+ has_wifi: boolean;
29
+ wifi_type: string | null;
30
+ has_power: boolean;
31
+ has_usb: boolean;
32
+ has_video: boolean;
33
+ video_type: string | null;
34
  }
35
 
36
  export interface FlightOffer {
frontend/src/components/results/FlightCard.tsx CHANGED
@@ -95,35 +95,113 @@ export default function FlightCard({ flight }: Props) {
95
  <div className="border-t border-gray-100 px-4 pt-3 pb-4" data-testid="segments-detail">
96
  {flight.segments.map((seg, i) => (
97
  <div key={i}>
98
- {/* Segment */}
99
- <div className="flex gap-3">
100
- {/* Timeline column */}
101
- <div className="flex flex-col items-center w-5 flex-shrink-0">
102
- <div className="h-2.5 w-2.5 rounded-full border-2 border-[#1a73e8] bg-white" />
103
- <div className="flex-1 w-px bg-gray-300 my-0.5" />
104
- <div className="h-2.5 w-2.5 rounded-full border-2 border-[#1a73e8] bg-white" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  </div>
106
 
107
- {/* Segment content */}
108
- <div className="flex-1 pb-1">
109
- {/* Departure */}
110
- <div className="flex items-baseline gap-2">
111
- <span className="text-sm font-medium w-16">{formatTime(seg.departure)}</span>
112
- <span className="text-sm text-gray-500">·</span>
113
- <span className="text-sm text-gray-900">{seg.origin_city} ({seg.origin})</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  </div>
115
 
116
- {/* Flight info line */}
117
- <div className="ml-[72px] my-1.5 text-xs text-gray-400 leading-relaxed">
118
- <div>Travel time: {formatDuration(seg.duration_minutes)}</div>
119
- <div>{seg.airline_name} · {seg.flight_number} · {seg.aircraft}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  </div>
121
 
122
- {/* Arrival */}
123
- <div className="flex items-baseline gap-2">
124
- <span className="text-sm font-medium w-16">{formatTime(seg.arrival)}</span>
125
- <span className="text-sm text-gray-500">·</span>
126
- <span className="text-sm text-gray-900">{seg.destination_city} ({seg.destination})</span>
 
 
 
 
 
 
 
 
 
 
 
 
127
  </div>
128
  </div>
129
  </div>
@@ -143,13 +221,16 @@ export default function FlightCard({ flight }: Props) {
143
  </div>
144
  ))}
145
 
146
- {/* Bottom info: emissions + total travel time */}
147
  <div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-4 text-xs text-gray-400">
148
  <span>Total travel time: {formatDuration(flight.total_duration_minutes)}</span>
149
  {flight.emissions_kg > 0 && (
150
  <>
151
  <span>·</span>
152
- <span>{flight.emissions_kg} kg CO₂ per passenger</span>
 
 
 
153
  </>
154
  )}
155
  </div>
 
95
  <div className="border-t border-gray-100 px-4 pt-3 pb-4" data-testid="segments-detail">
96
  {flight.segments.map((seg, i) => (
97
  <div key={i}>
98
+ {/* Segment: timeline+info on left, amenities on right */}
99
+ <div className="flex gap-4">
100
+ {/* Left side: timeline + segment content */}
101
+ <div className="flex gap-3 flex-1 min-w-0">
102
+ {/* Timeline column */}
103
+ <div className="flex flex-col items-center w-5 flex-shrink-0">
104
+ <div className="h-2.5 w-2.5 rounded-full border-2 border-[#1a73e8] bg-white" />
105
+ <div className="flex-1 w-px bg-gray-300 my-0.5" />
106
+ <div className="h-2.5 w-2.5 rounded-full border-2 border-[#1a73e8] bg-white" />
107
+ </div>
108
+
109
+ {/* Segment content */}
110
+ <div className="flex-1 pb-1 min-w-0">
111
+ {/* Departure */}
112
+ <div className="flex items-baseline gap-2">
113
+ <span className="text-sm font-medium w-16 flex-shrink-0">{formatTime(seg.departure)}</span>
114
+ <span className="text-sm text-gray-500">·</span>
115
+ <span className="text-sm text-gray-900">{seg.origin_city} ({seg.origin})</span>
116
+ </div>
117
+
118
+ {/* Flight info line */}
119
+ <div className="ml-[72px] my-1.5 text-xs text-gray-400 leading-relaxed">
120
+ <div>Travel time: {formatDuration(seg.duration_minutes)}</div>
121
+ <div>{seg.airline_name} · {seg.flight_number} · {seg.aircraft}</div>
122
+ </div>
123
+
124
+ {/* Arrival */}
125
+ <div className="flex items-baseline gap-2">
126
+ <span className="text-sm font-medium w-16 flex-shrink-0">{formatTime(seg.arrival)}</span>
127
+ <span className="text-sm text-gray-500">·</span>
128
+ <span className="text-sm text-gray-900">{seg.destination_city} ({seg.destination})</span>
129
+ </div>
130
+ </div>
131
  </div>
132
 
133
+ {/* Right side: amenities */}
134
+ <div className="flex-shrink-0 w-44 border-l border-gray-100 pl-4 text-xs text-gray-500 space-y-1.5 pt-0.5" data-testid="segment-amenities">
135
+ {/* Legroom */}
136
+ <div className="flex items-center gap-2">
137
+ <svg className="h-4 w-4 text-gray-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
138
+ <path d="M6 19v-4a2 2 0 012-2h1V7a2 2 0 012-2h2a2 2 0 012 2v6h1a2 2 0 012 2v4" strokeLinecap="round" strokeLinejoin="round"/>
139
+ <path d="M6 19h12" strokeLinecap="round"/>
140
+ </svg>
141
+ <span>{seg.legroom_inches} in legroom</span>
142
+ </div>
143
+
144
+ {/* WiFi */}
145
+ <div className="flex items-center gap-2">
146
+ {seg.has_wifi ? (
147
+ <>
148
+ <svg className="h-4 w-4 text-gray-400 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
149
+ <path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
150
+ </svg>
151
+ <span>{seg.wifi_type || 'Wi-Fi'}</span>
152
+ </>
153
+ ) : (
154
+ <>
155
+ <svg className="h-4 w-4 text-gray-300 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
156
+ <path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
157
+ </svg>
158
+ <span className="text-gray-300 line-through">Wi-Fi</span>
159
+ </>
160
+ )}
161
  </div>
162
 
163
+ {/* Power & USB */}
164
+ <div className="flex items-center gap-2">
165
+ {(seg.has_power || seg.has_usb) ? (
166
+ <>
167
+ <svg className="h-4 w-4 text-gray-400 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
168
+ <path d="M7 2v11h3v9l7-12h-4l4-8z"/>
169
+ </svg>
170
+ <span>
171
+ {seg.has_power && seg.has_usb
172
+ ? 'Power & USB'
173
+ : seg.has_power
174
+ ? 'In-seat power'
175
+ : 'USB charging'}
176
+ </span>
177
+ </>
178
+ ) : (
179
+ <>
180
+ <svg className="h-4 w-4 text-gray-300 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
181
+ <path d="M7 2v11h3v9l7-12h-4l4-8z"/>
182
+ </svg>
183
+ <span className="text-gray-300 line-through">Power</span>
184
+ </>
185
+ )}
186
  </div>
187
 
188
+ {/* Video/Entertainment */}
189
+ <div className="flex items-center gap-2">
190
+ {seg.has_video ? (
191
+ <>
192
+ <svg className="h-4 w-4 text-gray-400 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
193
+ <path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>
194
+ </svg>
195
+ <span>{seg.video_type || 'On-demand video'}</span>
196
+ </>
197
+ ) : (
198
+ <>
199
+ <svg className="h-4 w-4 text-gray-300 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
200
+ <path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>
201
+ </svg>
202
+ <span className="text-gray-300 line-through">Entertainment</span>
203
+ </>
204
+ )}
205
  </div>
206
  </div>
207
  </div>
 
221
  </div>
222
  ))}
223
 
224
+ {/* Bottom info: emissions + cabin class + total travel time */}
225
  <div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-4 text-xs text-gray-400">
226
  <span>Total travel time: {formatDuration(flight.total_duration_minutes)}</span>
227
  {flight.emissions_kg > 0 && (
228
  <>
229
  <span>·</span>
230
+ <svg className="h-3.5 w-3.5 inline text-green-500" viewBox="0 0 24 24" fill="currentColor">
231
+ <path d="M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66.95-2.3c.48.17.98.3 1.34.3C19 20 22 3 22 3c-1 2-8 2.25-13 3.25S2 11.5 2 13.5s1.75 3.75 1.75 3.75C7 8 17 8 17 8z"/>
232
+ </svg>
233
+ <span className="text-green-600">{flight.emissions_kg} kg CO₂</span>
234
  </>
235
  )}
236
  </div>