fyliu Claude Opus 4.6 commited on
Commit
9d442c9
·
1 Parent(s): a8b86e5

Fix local-time display bug, add Asia departure patterns, popularity extension

Browse files

Major bug fix: formatTime/formatDate/dayDiff/departure-time-filter all used
new Date() which converts to browser timezone. Now extract time directly
from ISO strings to preserve airport local timezone.

Backend fixes:
- Add "asia_to_eu" departure weights (23:00-02:30 late-night pattern)
- Fix "asia_to_na" to peak in afternoon (12-3 PM) not evening
- Overnight-exempt routes skip popularity window and curfew
- Popularity latest-hour gets random +0-1.5h extension

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

backend/config.py CHANGED
@@ -97,14 +97,18 @@ DEPARTURE_WEIGHTS: dict[str, list[int]] = {
97
  "na_to_asia": [
98
  0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 5, 12, 15, 14, 10, 7, 4, 2, 1, 0, 0, 0, 0, 0,
99
  ],
100
- # Asia → NA: Evening departures, arrive same day NA
101
  "asia_to_na": [
102
- 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 3, 3, 3, 3, 3, 4, 6, 10, 14, 13, 9, 6, 3, 1,
103
  ],
104
- # EU → Asia / AS → EU: afternoon–evening lean
105
  "eu_to_asia": [
106
  0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 5, 5, 6, 7, 8, 8, 7, 5, 3, 2, 1, 0,
107
  ],
 
 
 
 
108
  # Domestic / same-continent: morning rush + evening rush
109
  "domestic": [
110
  0, 0, 0, 0, 0, 1, 4, 8, 10, 8, 6, 5, 5, 5, 5, 5, 6, 8, 8, 5, 3, 2, 1, 0,
@@ -143,6 +147,15 @@ HUB_FLIGHT_BONUS: list[tuple[int, int, int]] = [
143
  (50, 0, 2),
144
  ]
145
 
 
 
 
 
 
 
 
 
 
146
  # Departure-weight per-hour jitter range (±fraction applied to each hourly weight)
147
  DEPARTURE_WEIGHT_JITTER = 0.20
148
 
 
97
  "na_to_asia": [
98
  0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 5, 12, 15, 14, 10, 7, 4, 2, 1, 0, 0, 0, 0, 0,
99
  ],
100
+ # Asia → NA: Afternoon departures, arrive same day NA
101
  "asia_to_na": [
102
+ 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 5, 7, 10, 14, 14, 10, 7, 4, 2, 1, 0, 0, 0, 0,
103
  ],
104
+ # EU → Asia: afternoon–evening lean
105
  "eu_to_asia": [
106
  0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 5, 5, 6, 7, 8, 8, 7, 5, 3, 2, 1, 0,
107
  ],
108
+ # Asia → EU: late-night departures (23:00–02:30), arrive morning EU
109
+ "asia_to_eu": [
110
+ 5, 3, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3, 8, 14,
111
+ ],
112
  # Domestic / same-continent: morning rush + evening rush
113
  "domestic": [
114
  0, 0, 0, 0, 0, 1, 4, 8, 10, 8, 6, 5, 5, 5, 5, 5, 6, 8, 8, 5, 3, 2, 1, 0,
 
147
  (50, 0, 2),
148
  ]
149
 
150
+ # Route types with intentional late-night / early-morning departures.
151
+ # These skip the popularity window and airport curfew so the overnight
152
+ # pattern encoded in DEPARTURE_WEIGHTS is preserved.
153
+ OVERNIGHT_EXEMPT_ROUTES: set[str] = {"asia_to_eu"}
154
+
155
+ # Maximum random extension to the popularity latest-hour (in minutes).
156
+ # Popular routes can have departures up to this much later than the base limit.
157
+ POPULARITY_LATEST_EXTENSION_MAX = 90 # 1.5 hours
158
+
159
  # Departure-weight per-hour jitter range (±fraction applied to each hourly weight)
160
  DEPARTURE_WEIGHT_JITTER = 0.20
161
 
backend/flight_generator.py CHANGED
@@ -21,6 +21,8 @@ from .config import (
21
  MAX_LAYOVER_MINUTES,
22
  MIN_FLIGHTS_PER_DAY,
23
  MIN_LAYOVER_MINUTES,
 
 
24
  POPULARITY_TIME_LIMITS,
25
  POWER_AIRCRAFT,
26
  PREMIUM_CLASS_AMENITY_BOOST,
@@ -117,8 +119,7 @@ def _classify_route_type(origin_continent: str, dest_continent: str) -> str:
117
  if o == "EU" and d == "AS":
118
  return "eu_to_asia"
119
  if o == "AS" and d == "EU":
120
- # Reverse of eu_to_asia: mirror the afternoon/evening pattern
121
- return "eu_to_asia"
122
  return "default"
123
 
124
 
@@ -131,29 +132,39 @@ def _build_departure_weights(
131
  """Build hourly departure weights adjusted for popularity, curfew, and jitter.
132
 
133
  Returns a 24-element list of non-negative floats (one weight per hour 0–23).
 
 
 
 
134
  """
135
  base = list(DEPARTURE_WEIGHTS.get(route_type, DEPARTURE_WEIGHTS["default"]))
136
  weights = [float(w) for w in base]
137
 
138
- # 1. Popularity-based time window: zero out hours outside the allowed range
139
- earliest, latest = 7, 21 # defaults
140
- for min_carriers, eh, lh in POPULARITY_TIME_LIMITS:
141
- if num_carriers >= min_carriers:
142
- earliest, latest = eh, lh
143
- break
144
- for h in range(0, earliest):
145
- weights[h] = 0.0
146
- for h in range(latest + 1, 24):
147
- weights[h] = 0.0
148
-
149
- # 2. Airport curfew: reduce midnight–5 AM based on airport size
150
- curfew_factor = 0.01
151
- for min_routes, factor in CURFEW_FACTORS:
152
- if origin_route_count >= min_routes:
153
- curfew_factor = factor
154
- break
155
- for h in range(0, 6):
156
- weights[h] *= curfew_factor
 
 
 
 
 
 
157
 
158
  # 3. Per-route jitter: ±DEPARTURE_WEIGHT_JITTER on each hour
159
  for h in range(24):
 
21
  MAX_LAYOVER_MINUTES,
22
  MIN_FLIGHTS_PER_DAY,
23
  MIN_LAYOVER_MINUTES,
24
+ OVERNIGHT_EXEMPT_ROUTES,
25
+ POPULARITY_LATEST_EXTENSION_MAX,
26
  POPULARITY_TIME_LIMITS,
27
  POWER_AIRCRAFT,
28
  PREMIUM_CLASS_AMENITY_BOOST,
 
119
  if o == "EU" and d == "AS":
120
  return "eu_to_asia"
121
  if o == "AS" and d == "EU":
122
+ return "asia_to_eu"
 
123
  return "default"
124
 
125
 
 
132
  """Build hourly departure weights adjusted for popularity, curfew, and jitter.
133
 
134
  Returns a 24-element list of non-negative floats (one weight per hour 0–23).
135
+
136
+ Overnight-exempt routes (e.g. asia_to_eu with 23:00–02:30 departures)
137
+ skip the popularity window and curfew so their late-night pattern is
138
+ preserved intact.
139
  """
140
  base = list(DEPARTURE_WEIGHTS.get(route_type, DEPARTURE_WEIGHTS["default"]))
141
  weights = [float(w) for w in base]
142
 
143
+ is_overnight = route_type in OVERNIGHT_EXEMPT_ROUTES
144
+
145
+ if not is_overnight:
146
+ # 1. Popularity-based time window: zero out hours outside the range
147
+ earliest, latest = 7, 21 # defaults
148
+ for min_carriers, eh, lh in POPULARITY_TIME_LIMITS:
149
+ if num_carriers >= min_carriers:
150
+ earliest, latest = eh, lh
151
+ break
152
+ # Random extension up to 1.5 h on the latest hour
153
+ if num_carriers >= 2:
154
+ latest = min(23, latest + rng.randint(0, POPULARITY_LATEST_EXTENSION_MAX) // 60)
155
+ for h in range(0, earliest):
156
+ weights[h] = 0.0
157
+ for h in range(latest + 1, 24):
158
+ weights[h] = 0.0
159
+
160
+ # 2. Airport curfew: reduce midnight–5 AM based on airport size
161
+ curfew_factor = 0.01
162
+ for min_routes, factor in CURFEW_FACTORS:
163
+ if origin_route_count >= min_routes:
164
+ curfew_factor = factor
165
+ break
166
+ for h in range(0, 6):
167
+ weights[h] *= curfew_factor
168
 
169
  # 3. Per-route jitter: ±DEPARTURE_WEIGHT_JITTER on each hour
170
  for h in range(24):
frontend/src/components/results/FlightCard.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState } from 'react';
2
  import type { FlightOffer } from '../../api/types';
3
- import { formatDuration, formatPrice, formatStops, formatTime } from '../../utils/format';
4
 
5
  interface Props {
6
  flight: FlightOffer;
@@ -14,9 +14,8 @@ export default function FlightCard({ flight }: Props) {
14
  const [expanded, setExpanded] = useState(false);
15
  const firstSeg = flight.segments[0];
16
 
17
- const depDate = new Date(flight.departure).toDateString();
18
- const arrDate = new Date(flight.arrival).toDateString();
19
- const dayDiff = depDate !== arrDate;
20
 
21
  // All airlines in this itinerary
22
  const airlineNames = [...new Set(flight.segments.map(s => s.airline_name))];
@@ -46,7 +45,7 @@ export default function FlightCard({ flight }: Props) {
46
  </span>
47
  <span className="text-gray-400 text-sm">&ndash;</span>
48
  <span className="text-[15px] font-medium whitespace-nowrap" data-testid="arrival-time">
49
- {formatTime(flight.arrival)}{dayDiff && <sup className="text-[10px] text-red-500 ml-px">+1</sup>}
50
  </span>
51
  </div>
52
 
 
1
  import { useState } from 'react';
2
  import type { FlightOffer } from '../../api/types';
3
+ import { daysBetween, formatDuration, formatPrice, formatStops, formatTime } from '../../utils/format';
4
 
5
  interface Props {
6
  flight: FlightOffer;
 
14
  const [expanded, setExpanded] = useState(false);
15
  const firstSeg = flight.segments[0];
16
 
17
+ // Compare calendar dates in their respective local timezones (not browser TZ)
18
+ const daysDiff = daysBetween(flight.departure, flight.arrival);
 
19
 
20
  // All airlines in this itinerary
21
  const airlineNames = [...new Set(flight.segments.map(s => s.airline_name))];
 
45
  </span>
46
  <span className="text-gray-400 text-sm">&ndash;</span>
47
  <span className="text-[15px] font-medium whitespace-nowrap" data-testid="arrival-time">
48
+ {formatTime(flight.arrival)}{daysDiff > 0 && <sup className="text-[10px] text-red-500 ml-px">+{daysDiff}</sup>}
49
  </span>
50
  </div>
51
 
frontend/src/pages/ResultsPage.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useCallback, useEffect, useMemo, useState } from 'react';
2
  import { useSearchParams } from 'react-router-dom';
3
  import type { CabinClass, Filters, Passengers, SortBy, TripType } from '../api/types';
 
4
  import SearchForm from '../components/search/SearchForm';
5
  import type { SearchFormData } from '../components/search/SearchForm';
6
  import FlightCard from '../components/results/FlightCard';
@@ -68,18 +69,12 @@ export default function ResultsPage() {
68
  if (filters.departure_time_min) {
69
  const [h, m] = filters.departure_time_min.split(':').map(Number);
70
  const min = h * 60 + m;
71
- flights = flights.filter(f => {
72
- const d = new Date(f.departure);
73
- return d.getHours() * 60 + d.getMinutes() >= min;
74
- });
75
  }
76
  if (filters.departure_time_max) {
77
  const [h, m] = filters.departure_time_max.split(':').map(Number);
78
  const max = h * 60 + m;
79
- flights = flights.filter(f => {
80
- const d = new Date(f.departure);
81
- return d.getHours() * 60 + d.getMinutes() <= max;
82
- });
83
  }
84
 
85
  // Sort
 
1
  import { useCallback, useEffect, useMemo, useState } from 'react';
2
  import { useSearchParams } from 'react-router-dom';
3
  import type { CabinClass, Filters, Passengers, SortBy, TripType } from '../api/types';
4
+ import { getLocalMinuteOfDay } from '../utils/format';
5
  import SearchForm from '../components/search/SearchForm';
6
  import type { SearchFormData } from '../components/search/SearchForm';
7
  import FlightCard from '../components/results/FlightCard';
 
69
  if (filters.departure_time_min) {
70
  const [h, m] = filters.departure_time_min.split(':').map(Number);
71
  const min = h * 60 + m;
72
+ flights = flights.filter(f => getLocalMinuteOfDay(f.departure) >= min);
 
 
 
73
  }
74
  if (filters.departure_time_max) {
75
  const [h, m] = filters.departure_time_max.split(':').map(Number);
76
  const max = h * 60 + m;
77
+ flights = flights.filter(f => getLocalMinuteOfDay(f.departure) <= max);
 
 
 
78
  }
79
 
80
  // Sort
frontend/src/utils/format.ts CHANGED
@@ -11,17 +11,41 @@ export function formatDuration(minutes: number): string {
11
  }
12
 
13
  export function formatTime(isoString: string): string {
14
- const d = new Date(isoString);
15
- return d.toLocaleTimeString('en-US', {
16
- hour: 'numeric',
17
- minute: '2-digit',
18
- hour12: true,
19
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
 
22
  export function formatDate(isoString: string): string {
23
- const d = new Date(isoString);
24
- return d.toLocaleDateString('en-US', {
 
 
 
25
  weekday: 'short',
26
  month: 'short',
27
  day: 'numeric',
 
11
  }
12
 
13
  export function formatTime(isoString: string): string {
14
+ // Extract HH:MM directly from ISO string to preserve the airport's
15
+ // local timezone. new Date() would convert to the browser's timezone.
16
+ const match = isoString.match(/T(\d{2}):(\d{2})/);
17
+ if (!match) {
18
+ return isoString;
19
+ }
20
+ const hours = parseInt(match[1], 10);
21
+ const minutes = match[2];
22
+ const ampm = hours >= 12 ? 'PM' : 'AM';
23
+ const displayHour = hours % 12 || 12;
24
+ return `${displayHour}:${minutes} ${ampm}`;
25
+ }
26
+
27
+ /** Minutes since midnight extracted from ISO string (airport local time). */
28
+ export function getLocalMinuteOfDay(isoString: string): number {
29
+ const match = isoString.match(/T(\d{2}):(\d{2})/);
30
+ if (!match) return 0;
31
+ return parseInt(match[1], 10) * 60 + parseInt(match[2], 10);
32
+ }
33
+
34
+ /** Number of calendar days between two ISO date-times (in their local timezones). */
35
+ export function daysBetween(isoA: string, isoB: string): number {
36
+ const dateA = isoA.split('T')[0];
37
+ const dateB = isoB.split('T')[0];
38
+ return Math.round(
39
+ (new Date(dateB).getTime() - new Date(dateA).getTime()) / 86400000,
40
+ );
41
  }
42
 
43
  export function formatDate(isoString: string): string {
44
+ // Parse from the date portion of the ISO string (airport local date)
45
+ const datePart = isoString.split('T')[0];
46
+ const [y, m, d] = datePart.split('-').map(Number);
47
+ const dt = new Date(y, m - 1, d); // local-date-only, no TZ conversion
48
+ return dt.toLocaleDateString('en-US', {
49
  weekday: 'short',
50
  month: 'short',
51
  day: 'numeric',