@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
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
- queryKey: ['step2-dates', { number }],
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 {