@autobusal/routes-order 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/Found/Dates/Dates.tsx +38 -2
- package/Found/Dates/styles.ts +37 -3
- package/Found/Filters/Filters.tsx +191 -0
- package/Found/Filters/styles.ts +117 -0
- package/Found/Found.tsx +107 -16
- package/Found/Group/Group.tsx +66 -0
- package/Found/Group/styles.ts +44 -0
- package/Found/Orders/Orders.tsx +67 -26
- package/Found/Orders/styles.ts +14 -0
- package/Found/Picks/Picks.tsx +68 -0
- package/Found/Picks/styles.ts +55 -0
- package/Found/Route/Route.tsx +24 -2
- package/Found/Route/styles.ts +10 -2
- package/Found/Sort/Sort.tsx +40 -0
- package/Found/Sort/styles.ts +42 -0
- package/Found/Summary/Summary.tsx +49 -0
- package/Found/Summary/styles.ts +30 -0
- package/Found/refine.ts +583 -0
- package/package.json +1 -1
- package/services.ts +7 -1
- package/types.ts +6 -0
package/Found/refine.ts
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { FoundData } from '@autobusal/providers/types/routes';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sorting, filtering and facet counting for the results list.
|
|
5
|
+
*
|
|
6
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
7
|
+
*
|
|
8
|
+
* All of it runs client-side against the array the search already returned:
|
|
9
|
+
* every field needed is on `FoundData`, so refining the list costs no extra
|
|
10
|
+
* request and stays instant while the user plays with the controls.
|
|
11
|
+
*
|
|
12
|
+
* One rule runs through the whole file: **an external-provider offer must
|
|
13
|
+
* never be filtered out by a facet it cannot report.** Those rows carry an
|
|
14
|
+
* operator and a price but no local stop ids, no `stops_count` and no
|
|
15
|
+
* amenity list, so a naive predicate would quietly delete paid partner
|
|
16
|
+
* inventory from the results. Every predicate below treats "the row cannot
|
|
17
|
+
* answer this question" as a pass, not a fail.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type SortKey = 'recommended' | 'cheapest' | 'fastest' | 'earliest' | 'latest';
|
|
21
|
+
|
|
22
|
+
export type BucketKey = '0' | '6' | '12' | '18';
|
|
23
|
+
|
|
24
|
+
export const SORTS: SortKey[] = [ 'recommended', 'cheapest', 'fastest', 'earliest', 'latest' ];
|
|
25
|
+
|
|
26
|
+
export const BUCKETS: BucketKey[] = [ '0', '6', '12', '18' ];
|
|
27
|
+
|
|
28
|
+
export interface Refinement {
|
|
29
|
+
sort: SortKey
|
|
30
|
+
operators: number[]
|
|
31
|
+
buckets: BucketKey[]
|
|
32
|
+
direct: boolean
|
|
33
|
+
fromStops: number[]
|
|
34
|
+
toStops: number[]
|
|
35
|
+
features: number[]
|
|
36
|
+
maxPrice: number | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FacetValue {
|
|
40
|
+
value: number
|
|
41
|
+
label: string
|
|
42
|
+
count: number
|
|
43
|
+
from: number | null
|
|
44
|
+
fromDisplay: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Facets {
|
|
48
|
+
operators: FacetValue[]
|
|
49
|
+
buckets: FacetValue[]
|
|
50
|
+
fromStops: FacetValue[]
|
|
51
|
+
toStops: FacetValue[]
|
|
52
|
+
features: FacetValue[]
|
|
53
|
+
direct: number
|
|
54
|
+
hasDirect: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const EMPTY: Refinement = {
|
|
58
|
+
sort: 'recommended',
|
|
59
|
+
operators: [],
|
|
60
|
+
buckets: [],
|
|
61
|
+
direct: false,
|
|
62
|
+
fromStops: [],
|
|
63
|
+
toStops: [],
|
|
64
|
+
features: [],
|
|
65
|
+
maxPrice: null
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* "HH:MM" as minutes past midnight. Anything unparseable is 0 rather than
|
|
70
|
+
* NaN, which would poison every comparison it touched.
|
|
71
|
+
*/
|
|
72
|
+
export const minutesOf = (time?: string): number => {
|
|
73
|
+
const [ hours, minutes ] = (time ?? '').split(':');
|
|
74
|
+
|
|
75
|
+
const value = Number(hours) * 60 + Number(minutes);
|
|
76
|
+
|
|
77
|
+
return Number.isFinite(value) ? value : 0;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const departureOf = (item: FoundData): number => (
|
|
81
|
+
minutesOf(item.locations.from.departure)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Trip length in minutes, or null when obtapi could not compute one.
|
|
86
|
+
*
|
|
87
|
+
* `Locations::duration()` emits a literal "-" when the arithmetic comes out
|
|
88
|
+
* negative (bad location times on the route). Parsing that as 0 would make
|
|
89
|
+
* a broken route the "fastest" trip on the page and float it to the top of
|
|
90
|
+
* the list, so it stays null and sorts last.
|
|
91
|
+
*/
|
|
92
|
+
export const durationOf = (item: FoundData): number | null => {
|
|
93
|
+
if (!item.duration || item.duration === '-') {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return minutesOf(item.duration);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const bucketOf = (item: FoundData): BucketKey => {
|
|
101
|
+
const hour = Math.floor(departureOf(item) / 60);
|
|
102
|
+
|
|
103
|
+
if (hour < 6) {
|
|
104
|
+
return '0';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (hour < 12) {
|
|
108
|
+
return '6';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (hour < 18) {
|
|
112
|
+
return '12';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return '18';
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const featuresOf = (item: FoundData): number[] | null => {
|
|
119
|
+
const features = item.trip?.features_data;
|
|
120
|
+
|
|
121
|
+
// null = the row cannot answer, so amenity filters must not judge it
|
|
122
|
+
return Array.isArray(features) ? features.map(feature => feature.id) : null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read the refinement out of the URL so a filtered result set survives a
|
|
127
|
+
* reload and can be pasted to somebody else.
|
|
128
|
+
*/
|
|
129
|
+
export const fromParams = (params: URLSearchParams): Refinement => {
|
|
130
|
+
const numbers = (name: string): number[] => (
|
|
131
|
+
(params.get(name) ?? '')
|
|
132
|
+
.split(',')
|
|
133
|
+
.map(Number)
|
|
134
|
+
.filter(value => Number.isFinite(value) && value > 0)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const sort = params.get('sort') as SortKey | null;
|
|
138
|
+
|
|
139
|
+
const maxPrice = Number(params.get('max_price'));
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
sort: sort && SORTS.includes(sort) ? sort : 'recommended',
|
|
143
|
+
operators: numbers('operators'),
|
|
144
|
+
buckets: (params.get('times') ?? '').split(',').filter(value => BUCKETS.includes(value as BucketKey)) as BucketKey[],
|
|
145
|
+
direct: params.get('direct') === '1',
|
|
146
|
+
fromStops: numbers('from_stops'),
|
|
147
|
+
toStops: numbers('to_stops'),
|
|
148
|
+
features: numbers('features'),
|
|
149
|
+
maxPrice: Number.isFinite(maxPrice) && maxPrice > 0 ? maxPrice : null
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Only non-default values are written, so an untouched page keeps a clean
|
|
155
|
+
* URL and the search's own query parameters stay readable.
|
|
156
|
+
*/
|
|
157
|
+
export const toParams = (refinement: Refinement): Record<string, string> => {
|
|
158
|
+
const params: Record<string, string> = {};
|
|
159
|
+
|
|
160
|
+
if (refinement.sort !== 'recommended') {
|
|
161
|
+
params.sort = refinement.sort;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (refinement.operators.length > 0) {
|
|
165
|
+
params.operators = refinement.operators.join(',');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (refinement.buckets.length > 0) {
|
|
169
|
+
params.times = refinement.buckets.join(',');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (refinement.direct) {
|
|
173
|
+
params.direct = '1';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (refinement.fromStops.length > 0) {
|
|
177
|
+
params.from_stops = refinement.fromStops.join(',');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (refinement.toStops.length > 0) {
|
|
181
|
+
params.to_stops = refinement.toStops.join(',');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (refinement.features.length > 0) {
|
|
185
|
+
params.features = refinement.features.join(',');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (refinement.maxPrice !== null) {
|
|
189
|
+
params.max_price = String(refinement.maxPrice);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return params;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const isRefined = (refinement: Refinement): boolean => (
|
|
196
|
+
Object.keys(toParams({ ...refinement, sort: 'recommended' })).length > 0
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
type Facet = 'operators' | 'buckets' | 'direct' | 'fromStops' | 'toStops' | 'features' | 'maxPrice';
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether a row survives the refinement. `except` drops one facet from the
|
|
203
|
+
* test, which is how each facet's own counts are computed against the
|
|
204
|
+
* *other* active filters - the behaviour people expect from faceted search,
|
|
205
|
+
* where ticking one operator doesn't zero out every other operator's count.
|
|
206
|
+
*/
|
|
207
|
+
export const matches = (item: FoundData, refinement: Refinement, except?: Facet): boolean => {
|
|
208
|
+
if (except !== 'operators' && refinement.operators.length > 0) {
|
|
209
|
+
if (!refinement.operators.includes(item.operator.id)) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (except !== 'buckets' && refinement.buckets.length > 0) {
|
|
215
|
+
if (!refinement.buckets.includes(bucketOf(item))) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// undefined stops_count = an external offer that doesn't report stops
|
|
221
|
+
if (except !== 'direct' && refinement.direct && item.stops_count !== undefined) {
|
|
222
|
+
if (item.stops_count > 0) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (except !== 'fromStops' && refinement.fromStops.length > 0) {
|
|
228
|
+
const stop = item.locations.from.stop?.id;
|
|
229
|
+
|
|
230
|
+
if (stop !== undefined && !refinement.fromStops.includes(stop)) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (except !== 'toStops' && refinement.toStops.length > 0) {
|
|
236
|
+
const stop = item.locations.to.stop?.id;
|
|
237
|
+
|
|
238
|
+
if (stop !== undefined && !refinement.toStops.includes(stop)) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (except !== 'features' && refinement.features.length > 0) {
|
|
244
|
+
const features = featuresOf(item);
|
|
245
|
+
|
|
246
|
+
// every ticked amenity must be present - narrowing, not widening
|
|
247
|
+
if (features !== null && !refinement.features.every(id => features.includes(id))) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (except !== 'maxPrice' && refinement.maxPrice !== null) {
|
|
253
|
+
if (item.price.value > refinement.maxPrice) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return true;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Duration comparison that sinks routes with no computable duration to the
|
|
263
|
+
* bottom, rather than letting obtapi's "-" sentinel pose as a zero-minute
|
|
264
|
+
* trip and win "fastest".
|
|
265
|
+
*/
|
|
266
|
+
const byDuration = (a: FoundData, b: FoundData): number => {
|
|
267
|
+
const first = durationOf(a);
|
|
268
|
+
const second = durationOf(b);
|
|
269
|
+
|
|
270
|
+
if (first === null && second === null) {
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (first === null) {
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (second === null) {
|
|
279
|
+
return -1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return first - second;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
287
|
+
*
|
|
288
|
+
* Every sort carries a tiebreak on the other axis, because the head of the
|
|
289
|
+
* list is also what the quick-pick cards advertise. Without it, three trips
|
|
290
|
+
* tied at the same journey time meant "fastest" could headline the dearest
|
|
291
|
+
* of them - true, but a useless thing to show somebody.
|
|
292
|
+
*/
|
|
293
|
+
const compare = (sort: SortKey) => (a: FoundData, b: FoundData): number => {
|
|
294
|
+
switch (sort) {
|
|
295
|
+
case 'cheapest':
|
|
296
|
+
return (a.price.value - b.price.value) || byDuration(a, b);
|
|
297
|
+
|
|
298
|
+
case 'earliest':
|
|
299
|
+
return (departureOf(a) - departureOf(b)) || (a.price.value - b.price.value);
|
|
300
|
+
|
|
301
|
+
case 'latest':
|
|
302
|
+
return (departureOf(b) - departureOf(a)) || (a.price.value - b.price.value);
|
|
303
|
+
|
|
304
|
+
case 'fastest':
|
|
305
|
+
return byDuration(a, b) || (a.price.value - b.price.value);
|
|
306
|
+
|
|
307
|
+
default:
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Dense rank of each item under a scoring function, best (lowest) first.
|
|
314
|
+
* Equal values share a rank, so ten identically priced trips are all "the
|
|
315
|
+
* cheapest" rather than being arbitrarily spread over ten positions.
|
|
316
|
+
*/
|
|
317
|
+
const rankBy = (items: FoundData[], score: (item: FoundData) => number | null): Map<FoundData, number> => {
|
|
318
|
+
const known = items.filter(item => score(item) !== null);
|
|
319
|
+
|
|
320
|
+
const values = [ ...new Set(known.map(item => score(item) as number)) ].sort((a, b) => a - b);
|
|
321
|
+
|
|
322
|
+
const ranks = new Map<FoundData, number>();
|
|
323
|
+
|
|
324
|
+
items.forEach(item => {
|
|
325
|
+
const value = score(item);
|
|
326
|
+
|
|
327
|
+
// an unscoreable item ranks below every scoreable one
|
|
328
|
+
ranks.set(item, value === null ? values.length : values.indexOf(value));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return ranks;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* "Recommended" ordering.
|
|
336
|
+
*
|
|
337
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
338
|
+
*
|
|
339
|
+
* Merit first: a trip's standing is its price rank plus its duration rank,
|
|
340
|
+
* both computed across the current result set. The operator's paid
|
|
341
|
+
* subscription priority is only a TIEBREAK between trips that are already
|
|
342
|
+
* equal on both - it cannot buy its way past a cheaper or faster
|
|
343
|
+
* competitor. That is a deliberate commercial decision (Ferjolt, 2026-08-01)
|
|
344
|
+
* and it is the answer if anyone asks what the word means on this page.
|
|
345
|
+
*
|
|
346
|
+
* Departure time is the final tiebreak, so the order stays stable and
|
|
347
|
+
* predictable rather than depending on the array's incoming arrangement.
|
|
348
|
+
*/
|
|
349
|
+
const recommend = (items: FoundData[]): FoundData[] => {
|
|
350
|
+
const price = rankBy(items, item => item.price.value);
|
|
351
|
+
const duration = rankBy(items, item => durationOf(item));
|
|
352
|
+
|
|
353
|
+
const score = (item: FoundData): number => (price.get(item) ?? 0) + (duration.get(item) ?? 0);
|
|
354
|
+
|
|
355
|
+
return [ ...items ].sort((a, b) => (
|
|
356
|
+
score(a) - score(b)
|
|
357
|
+
|| (b.priority ?? 0) - (a.priority ?? 0)
|
|
358
|
+
|| departureOf(a) - departureOf(b)
|
|
359
|
+
));
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Sort a list without touching the caller's array.
|
|
364
|
+
*/
|
|
365
|
+
export const sortItems = (items: FoundData[], sort: SortKey): FoundData[] => {
|
|
366
|
+
if (sort === 'recommended') {
|
|
367
|
+
return recommend(items);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return [ ...items ].sort(compare(sort));
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
export const filterItems = (items: FoundData[], refinement: Refinement): FoundData[] => (
|
|
374
|
+
items.filter(item => matches(item, refinement))
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Collapse interchangeable departures into one entry.
|
|
379
|
+
*
|
|
380
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
381
|
+
*
|
|
382
|
+
* Hourly shuttle service is the dominant Albanian intercity pattern, so a
|
|
383
|
+
* busy pair renders a wall of rows that differ only in the departure time -
|
|
384
|
+
* the same operator, the same two stops, the same fare, the same journey
|
|
385
|
+
* length. Those are one product with a choice of time, and reading them as
|
|
386
|
+
* twenty separate options is what makes the page tiring.
|
|
387
|
+
*
|
|
388
|
+
* Duration and price are BOTH in the key on purpose: without them a slower
|
|
389
|
+
* or dearer trip would hide inside a group under a headline it doesn't
|
|
390
|
+
* honour. External offers are keyed by their own id so they never merge
|
|
391
|
+
* with anything - they carry no stop ids, and grouping on missing values
|
|
392
|
+
* would fuse unrelated partner inventory into a single row.
|
|
393
|
+
*
|
|
394
|
+
* Input order is preserved, so a group lands wherever its best member
|
|
395
|
+
* sorted to and the caller does not have to re-sort.
|
|
396
|
+
*/
|
|
397
|
+
export const groupItems = (items: FoundData[]): FoundData[][] => {
|
|
398
|
+
const groups = new Map<string, FoundData[]>();
|
|
399
|
+
const order: string[] = [];
|
|
400
|
+
|
|
401
|
+
items.forEach(item => {
|
|
402
|
+
const key = item.external
|
|
403
|
+
? `ext:${ item.id }`
|
|
404
|
+
: [
|
|
405
|
+
item.operator.id,
|
|
406
|
+
item.locations.from.stop?.id ?? '-',
|
|
407
|
+
item.locations.to.stop?.id ?? '-',
|
|
408
|
+
item.price.value,
|
|
409
|
+
item.duration
|
|
410
|
+
].join('|');
|
|
411
|
+
|
|
412
|
+
if (!groups.has(key)) {
|
|
413
|
+
groups.set(key, []);
|
|
414
|
+
order.push(key);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
groups.get(key)?.push(item);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
return order.map(key => groups.get(key) as FoundData[]);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const countBy = (
|
|
424
|
+
items: FoundData[],
|
|
425
|
+
refinement: Refinement,
|
|
426
|
+
except: Facet,
|
|
427
|
+
keys: (item: FoundData) => { value: number, label: string }[]
|
|
428
|
+
): FacetValue[] => {
|
|
429
|
+
const found = new Map<number, FacetValue>();
|
|
430
|
+
|
|
431
|
+
// Pass one establishes the value universe from the WHOLE result set, so a
|
|
432
|
+
// facet keeps its shape as boxes get ticked. Building it only from
|
|
433
|
+
// matching rows instead made options vanish mid-interaction - narrow to
|
|
434
|
+
// one operator and the operator group disappeared entirely, taking the
|
|
435
|
+
// way back out with it.
|
|
436
|
+
items.forEach(item => {
|
|
437
|
+
keys(item).forEach(({ value, label }) => {
|
|
438
|
+
if (!found.has(value)) {
|
|
439
|
+
found.set(value, { value, label, count: 0, from: null, fromDisplay: null });
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// pass two counts against the other active filters, leaving impossible
|
|
445
|
+
// values at zero to be rendered disabled rather than removed
|
|
446
|
+
items.forEach(item => {
|
|
447
|
+
if (!matches(item, refinement, except)) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
keys(item).forEach(({ value }) => {
|
|
452
|
+
const existing = found.get(value);
|
|
453
|
+
|
|
454
|
+
if (existing === undefined) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
existing.count += 1;
|
|
459
|
+
|
|
460
|
+
if (existing.from === null || item.price.value < existing.from) {
|
|
461
|
+
existing.from = item.price.value;
|
|
462
|
+
existing.fromDisplay = item.price.display;
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
return [ ...found.values() ].sort((a, b) => a.label.localeCompare(b.label));
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Every facet value present in the result set, each with how many rows it
|
|
472
|
+
* would leave and the cheapest fare behind it. Values are kept even at a
|
|
473
|
+
* count of zero so the panel doesn't reshuffle under the cursor as boxes
|
|
474
|
+
* get ticked - they render disabled instead of vanishing.
|
|
475
|
+
*/
|
|
476
|
+
export const buildFacets = (items: FoundData[], refinement: Refinement, bucketLabel: (key: BucketKey) => string): Facets => {
|
|
477
|
+
const operators = countBy(items, refinement, 'operators', item => [
|
|
478
|
+
{ value: item.operator.id, label: item.operator.company }
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
// same two-pass shape as countBy: a bucket is listed when the unfiltered
|
|
482
|
+
// result set has anything in it, and counted against the other filters
|
|
483
|
+
const buckets = BUCKETS
|
|
484
|
+
.filter(key => items.some(item => bucketOf(item) === key))
|
|
485
|
+
.map(key => {
|
|
486
|
+
const inBucket = items.filter(item => bucketOf(item) === key && matches(item, refinement, 'buckets'));
|
|
487
|
+
|
|
488
|
+
const cheapest = inBucket.reduce<FoundData | null>((best, item) => (
|
|
489
|
+
best === null || item.price.value < best.price.value ? item : best
|
|
490
|
+
), null);
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
value: Number(key),
|
|
494
|
+
label: bucketLabel(key),
|
|
495
|
+
count: inBucket.length,
|
|
496
|
+
from: cheapest?.price.value ?? null,
|
|
497
|
+
fromDisplay: cheapest?.price.display ?? null
|
|
498
|
+
};
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const fromStops = countBy(items, refinement, 'fromStops', item => (
|
|
502
|
+
item.locations.from.stop ? [ { value: item.locations.from.stop.id, label: item.locations.from.stop.name } ] : []
|
|
503
|
+
));
|
|
504
|
+
|
|
505
|
+
const toStops = countBy(items, refinement, 'toStops', item => (
|
|
506
|
+
item.locations.to.stop ? [ { value: item.locations.to.stop.id, label: item.locations.to.stop.name } ] : []
|
|
507
|
+
));
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Amenities are the one AND group: every ticked box must be present on
|
|
511
|
+
* the row, so "how many if I drop this whole group" - the right question
|
|
512
|
+
* for the OR groups above - would advertise 8 results next to WC while
|
|
513
|
+
* the page shows 1. These count what ticking the box would actually
|
|
514
|
+
* leave, which is the number the user is really asking for.
|
|
515
|
+
*/
|
|
516
|
+
const features = (() => {
|
|
517
|
+
const universe = new Map<number, string>();
|
|
518
|
+
|
|
519
|
+
items.forEach(item => {
|
|
520
|
+
(item.trip?.features_data ?? []).forEach(feature => {
|
|
521
|
+
if (!universe.has(feature.id)) {
|
|
522
|
+
universe.set(feature.id, feature.name);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
return [ ...universe.entries() ].map(([ value, label ]) => {
|
|
528
|
+
const selected = refinement.features.includes(value);
|
|
529
|
+
|
|
530
|
+
const candidate = selected ? refinement : { ...refinement, features: [ ...refinement.features, value ] };
|
|
531
|
+
|
|
532
|
+
const matching = items.filter(item => matches(item, candidate));
|
|
533
|
+
|
|
534
|
+
const cheapest = matching.reduce<FoundData | null>((best, item) => (
|
|
535
|
+
best === null || item.price.value < best.price.value ? item : best
|
|
536
|
+
), null);
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
value,
|
|
540
|
+
label,
|
|
541
|
+
count: matching.length,
|
|
542
|
+
from: cheapest?.price.value ?? null,
|
|
543
|
+
fromDisplay: cheapest?.price.display ?? null
|
|
544
|
+
};
|
|
545
|
+
}).sort((a, b) => a.label.localeCompare(b.label));
|
|
546
|
+
})();
|
|
547
|
+
|
|
548
|
+
const direct = items.filter(item => item.stops_count === 0 && matches(item, refinement, 'direct')).length;
|
|
549
|
+
|
|
550
|
+
// whether to OFFER the control at all, as opposed to what it would leave -
|
|
551
|
+
// the group stays put at a count of zero, like every other facet
|
|
552
|
+
const hasDirect = items.some(item => item.stops_count === 0);
|
|
553
|
+
|
|
554
|
+
return { operators, buckets, fromStops, toStops, features, direct, hasDirect };
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
export const priceRange = (items: FoundData[]): { min: number, max: number, minDisplay: string, maxDisplay: string } | null => {
|
|
558
|
+
if (items.length === 0) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const sorted = [ ...items ].sort((a, b) => a.price.value - b.price.value);
|
|
563
|
+
|
|
564
|
+
const cheapest = sorted[0];
|
|
565
|
+
const dearest = sorted[sorted.length - 1];
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
min: cheapest.price.value,
|
|
569
|
+
max: dearest.price.value,
|
|
570
|
+
minDisplay: cheapest.price.display,
|
|
571
|
+
maxDisplay: dearest.price.display
|
|
572
|
+
};
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The label's currency as obtapi formats it ("50.00 EUR" -> "EUR"), taken
|
|
577
|
+
* from a price the server already rendered rather than re-deriving it in
|
|
578
|
+
* the client. Used for values the server never formatted, like the position
|
|
579
|
+
* of the price slider.
|
|
580
|
+
*/
|
|
581
|
+
export const currencyOf = (items: FoundData[]): string => (
|
|
582
|
+
(items[0]?.price.display ?? '').replace(/[\d.,]/g, '').trim()
|
|
583
|
+
);
|
package/package.json
CHANGED
package/services.ts
CHANGED
|
@@ -52,7 +52,13 @@ export const useGetDates = (step1: RoutesSearchForm, type: ('departure' | '_retu
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
return useQuery({
|
|
55
|
-
|
|
55
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
56
|
+
// The key was ['step2-dates', { number }] - the search itself was not in
|
|
57
|
+
// it, so every city pair and passenger mix shared one cache entry and
|
|
58
|
+
// the strip kept showing the previous trip's days. Invisible while the
|
|
59
|
+
// strip was only booleans; with per-day prices on it, it would have
|
|
60
|
+
// quoted one route's fares under another route's dates.
|
|
61
|
+
queryKey: ['step2-dates', { step, number }],
|
|
56
62
|
queryFn: async () => (
|
|
57
63
|
await apiClient
|
|
58
64
|
.get('/api/routes/search/dates', {
|
package/types.ts
CHANGED
|
@@ -2,6 +2,12 @@ export interface FoundDay {
|
|
|
2
2
|
day: string
|
|
3
3
|
id: string
|
|
4
4
|
routes: boolean
|
|
5
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
6
|
+
// Cheapest fare that day for the current passenger mix, null when the day
|
|
7
|
+
// has nothing. Optional so an older obtapi - and any API partner reading
|
|
8
|
+
// /routes/search/dates - keeps type-checking.
|
|
9
|
+
from_price?: number | null
|
|
10
|
+
from_price_display?: string | null
|
|
5
11
|
}
|
|
6
12
|
|
|
7
13
|
export interface CouponForm {
|