@autobusal/routes-order 1.8.0 → 1.9.3

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