@autobusal/routes-order 1.37.4 → 1.37.6

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,166 @@
1
+ import { TFunction } from 'i18next';
2
+ import { NextDay } from '../Schedule/styles';
3
+ import { Container, Heading, Lead, Wrap, Table, Time, Faded, Fare, Price, SeatsLeft, Book, Later, More, Updated } from './styles';
4
+ import { LOW_SEATS, Day, Prepared } from './service';
5
+
6
+ interface Props {
7
+ id: string
8
+ // Built by ./service and handed in, because the BusTrip markup a few
9
+ // hundred pixels up the document is emitted from this very object. One
10
+ // builder, two consumers - see the header comment there.
11
+ data: Prepared
12
+ t: TFunction<'common'>
13
+ }
14
+
15
+ /**
16
+ * How many days are open when the section first renders.
17
+ *
18
+ * A week: enough that somebody asking "is there a bus this weekend" never
19
+ * has to expand anything, few enough that the page still ends.
20
+ */
21
+ const VISIBLE = 7;
22
+
23
+ /**
24
+ * Real departures on real dates, for the next thirty days.
25
+ *
26
+ * Edited: Claude - Date: 2026-09-16
27
+ *
28
+ * WHY THIS EXISTS. A customer asked an assistant for a bus from
29
+ * Thessaloniki to Tirana on the 20th of September. This page answered with
30
+ * a date-independent timetable - the services that exist, in the abstract -
31
+ * and said nothing whatsoever about the 20th, so neither the customer nor
32
+ * the assistant could tell whether a seat existed on it. The timetable is
33
+ * still below, and is still the right thing for "what time do the buses
34
+ * go"; this is the answer to "is there one on Thursday, and is it full".
35
+ *
36
+ * A DAY WITH NOTHING ON IT IS STILL A ROW. "No departures on this date" is
37
+ * information - it is, in fact, the harder half of the question to answer
38
+ * anywhere else - and a day quietly missing from the list reads as an
39
+ * oversight rather than as an answer.
40
+ *
41
+ * NOTHING IS EVER CONDITIONALLY MOUNTED. Days past the first week sit
42
+ * inside a <details>, which hides them without removing them: a crawler and
43
+ * an assistant read all thirty, a visitor scrolls seven. See Later in
44
+ * ./styles for what this codebase already paid to learn about accordions
45
+ * that mount only what is open.
46
+ */
47
+ const Availability = ({ id, data, t }: Props): JSX.Element => {
48
+ const columns = 6;
49
+
50
+ const body = (day: Day): JSX.Element => (
51
+ <tbody key={ day.date }>
52
+ <tr>
53
+ { /* The ISO date on the heading, so the machine-readable date is
54
+ the one under the words rather than one assembled elsewhere. */ }
55
+ <th colSpan={ columns } scope="colgroup">
56
+ <time dateTime={ day.date }>{ day.label }</time>
57
+ </th>
58
+ </tr>
59
+
60
+ { day.departures.length === 0 ? (
61
+ <tr>
62
+ <td className="none" colSpan={ columns }>{ t('routes_order.availability.none') }</td>
63
+ </tr>
64
+ ) : day.departures.map((row, index) => (
65
+ <tr key={ index }>
66
+ <td>
67
+ <Time dateTime={ row.departs ?? undefined }>{ row.time }</Time>
68
+ </td>
69
+
70
+ { /* The same +1 the timetable and the result cards carry, from the
71
+ same styled component - a row whose arrival is the following
72
+ morning and does not say so is the one row on the page
73
+ somebody will act on wrongly. */ }
74
+ <td>
75
+ { row.arrival ? (
76
+ <>
77
+ <Time dateTime={ row.arrives ?? undefined }>{ row.arrival }</Time>
78
+
79
+ { row.offset > 0 && (
80
+ <NextDay title={ t('routes_order.step2.route.next_day', { count: row.offset }) }>
81
+ +{ row.offset }
82
+ </NextDay>
83
+ ) }
84
+ </>
85
+ ) : <Faded>-</Faded> }
86
+ </td>
87
+
88
+ <td><Faded>{ row.duration ?? '-' }</Faded></td>
89
+
90
+ <td>{ row.operator ?? '-' }</td>
91
+
92
+ <td>
93
+ <Fare>
94
+ <Price>{ row.price ?? '-' }</Price>
95
+
96
+ { /* The same wording and the same threshold as the result
97
+ cards - "Seats left: 49", a label and a number, never a
98
+ counted noun that fifteen languages would have to make
99
+ agree. */ }
100
+ { row.seats !== null && (
101
+ <SeatsLeft $low={ row.seats <= LOW_SEATS }>
102
+ { t('routes_order.step2.route.seats_left', { count: row.seats }) }
103
+ </SeatsLeft>
104
+ ) }
105
+ </Fare>
106
+ </td>
107
+
108
+ <td>
109
+ { row.url ? <Book href={ row.url }>{ t('routes_order.availability.book') }</Book> : null }
110
+ </td>
111
+ </tr>
112
+ )) }
113
+ </tbody>
114
+ );
115
+
116
+ const table = (days: Day[]): JSX.Element => (
117
+ <Wrap>
118
+ <Table>
119
+ <thead>
120
+ <tr>
121
+ { /* The column vocabulary is the timetable's, deliberately -
122
+ two sections on one page that say "Departs" and "Departure"
123
+ about the same thing are two sections a reader has to
124
+ reconcile. Only the fare differs: the timetable's header is
125
+ "From" because its prices are the cheapest anybody sells the
126
+ leg for, and these are the actual fare on the actual date. */ }
127
+ <th>{ t('routes_order.schedule.departure') }</th>
128
+ <th>{ t('routes_order.schedule.arrival') }</th>
129
+ <th>{ t('routes_order.schedule.duration') }</th>
130
+ <th>{ t('routes_order.schedule.operator') }</th>
131
+ <th>{ t('routes_order.availability.price') }</th>
132
+ <th>{ t('routes_order.availability.book') }</th>
133
+ </tr>
134
+ </thead>
135
+
136
+ { days.map(body) }
137
+ </Table>
138
+ </Wrap>
139
+ );
140
+
141
+ const rest = data.days.slice(VISIBLE);
142
+
143
+ return (
144
+ <Container id={ id } className="box">
145
+ <Heading>{ t('routes_order.availability.title', { from: data.from, to: data.to }) }</Heading>
146
+
147
+ <Lead>{ t('routes_order.availability.lead') }</Lead>
148
+
149
+ { table(data.days.slice(0, VISIBLE)) }
150
+
151
+ { rest.length > 0 && (
152
+ <Later>
153
+ <More>{ t('routes_order.availability.more') }</More>
154
+
155
+ { table(rest) }
156
+ </Later>
157
+ ) }
158
+
159
+ { /* A seat count is only true of a moment, so the page says which
160
+ moment - and that is what earns the Offer markup its validFrom. */ }
161
+ { data.updated && <Updated>{ data.updated }</Updated> }
162
+ </Container>
163
+ );
164
+ };
165
+
166
+ export default Availability;
@@ -0,0 +1,109 @@
1
+ import { TFunction } from 'i18next';
2
+ import { JsonLd } from '@autobusal/common';
3
+ import { LOW_SEATS, Prepared } from './service';
4
+
5
+ interface Props {
6
+ // The SAME object the section renders from - see ./service. Not a second
7
+ // pass over the payload that happens to agree with the first one.
8
+ data: Prepared
9
+ t: TFunction<'common'>
10
+ }
11
+
12
+ /**
13
+ * One BusTrip per dated departure.
14
+ *
15
+ * Edited: Claude - Date: 2026-09-16
16
+ *
17
+ * The pair-level BusTrip in ../Sections/Schema describes the journey in the
18
+ * abstract - how long it takes, who runs it, what it costs from. This
19
+ * describes the actual coaches: this operator, leaving at this instant on
20
+ * this date, at this fare, with this many seats left. That is the thing an
21
+ * assistant was asked for and could not find.
22
+ *
23
+ * THE RULE THIS FILE INHERITS: never state here what the page does not
24
+ * show. It is not enforced by care - it is enforced by the fact that every
25
+ * value below comes out of the same prepared array the table is built from,
26
+ * including the datetimes, which the cells carry as their own `datetime`
27
+ * attributes. There is no path by which the markup can describe a departure
28
+ * the reader cannot see.
29
+ *
30
+ * CAPPED AT THE DAYS IN THE DOM, which is all thirty of them: the days past
31
+ * the first week are collapsed, not unmounted. If that ever changes, this
32
+ * has to be sliced to match - a thirty-day promise over a seven-day page is
33
+ * the mismatch that costs a site its rich results.
34
+ *
35
+ * ONE SCRIPT, not one per departure. A @graph of a hundred and fifty nodes
36
+ * is a single parse for a consumer and a single tag in the snapshot;
37
+ * scattering them would multiply the boilerplate by the number of coaches.
38
+ */
39
+ const Schema = ({ data, t }: Props): (JSX.Element | null) => {
40
+ const stop = (name: string) => ({ '@type': 'BusStop', name });
41
+
42
+ const trips = data.days.flatMap(day => day.departures.map(row => ({
43
+ '@type': 'BusTrip',
44
+ name: t('routes_order.facts.title', { from: data.from, to: data.to }),
45
+
46
+ departureBusStop: stop(data.from),
47
+ arrivalBusStop: stop(data.to),
48
+
49
+ // Real instants - date, clock time, and the offset of the city whose
50
+ // clock that time is read from: the origin's for the departure, the
51
+ // destination's for the arrival (see arrival_timezone in ./types). An overnight service's arrival is already on the following day;
52
+ // the payload's arrival_offset is what makes that knowable from two
53
+ // clock times. Absent rather than approximated when the date or the
54
+ // zone could not be resolved.
55
+ ...(row.departs ? { departureTime: row.departs } : {}),
56
+ ...(row.arrives ? { arrivalTime: row.arrives } : {}),
57
+
58
+ ...(row.operator ? {
59
+ provider: { '@type': 'Organization', name: row.operator }
60
+ } : {}),
61
+
62
+ // Only with a real ISO 4217 code and a real number: `price` formatted
63
+ // for display has the symbol baked in, and an Offer built by guessing a
64
+ // currency back out of it would be confidently wrong.
65
+ ...(row.amount !== null && data.currency ? {
66
+ offers: {
67
+ '@type': 'Offer',
68
+ price: row.amount,
69
+ priceCurrency: data.currency,
70
+
71
+ ...(row.url ? { url: row.url } : {}),
72
+
73
+ /*
74
+ * READ off the seat count the row prints, never asserted. Below the
75
+ * threshold that makes the cell bold, this says LimitedAvailability
76
+ * - the same claim, to the same standard, from the same number. A
77
+ * departure whose seat count is unknown is InStock, which is what
78
+ * listing it as bookable on a date already says.
79
+ */
80
+ availability: (row.seats !== null && row.seats <= LOW_SEATS)
81
+ ? 'https://schema.org/LimitedAvailability'
82
+ : 'https://schema.org/InStock',
83
+
84
+ /*
85
+ * WHEN THE FARE AND THE SEATS WERE MEASURED. Non-null only when the
86
+ * page prints that moment in its own footnote - see `checked` in
87
+ * ./service, where the two are tied together so that this cannot
88
+ * carry a timestamp a reader has no way to see.
89
+ */
90
+ ...(data.quoted ? { validFrom: data.quoted } : {}),
91
+
92
+ /*
93
+ * And when the offer stops being one: a seat cannot be bought after
94
+ * the coach has left. The instant is the departure the row shows,
95
+ * not a separate claim about a sales deadline.
96
+ */
97
+ ...(row.departs ? { availabilityEnds: row.departs } : {})
98
+ }
99
+ } : {})
100
+ })));
101
+
102
+ if (trips.length === 0) {
103
+ return null;
104
+ }
105
+
106
+ return <JsonLd data={ { '@context': 'https://schema.org', '@graph': trips } } />;
107
+ };
108
+
109
+ export default Schema;
@@ -0,0 +1,368 @@
1
+ import { TFunction } from 'i18next';
2
+ import { MONTHS } from '../Facts/service';
3
+ import { DAYS as WEEKDAYS, duration as spell } from '../Facts/questions';
4
+ import { AvailabilityResponse } from './types';
5
+
6
+ /**
7
+ * Every departure this section shows, ready to render.
8
+ *
9
+ * Edited: Claude - Date: 2026-09-16
10
+ *
11
+ * ONE BUILDER, TWO CONSUMERS, and that is the entire point - the same
12
+ * argument ../Facts/questions makes for the FAQ. The table a human reads
13
+ * and the BusTrip markup a machine reads are both built from the array this
14
+ * returns, so every datetime, fare and seat count in the structured data is
15
+ * literally the value printed a few pixels away. The rule this codebase
16
+ * enforces - never emit what the page does not show - stops being a thing
17
+ * somebody has to remember and becomes a thing the types make true.
18
+ *
19
+ * It is also where everything the wire can get wrong is dealt with, once: a
20
+ * date that does not parse, a departure with no clock time, a payload with
21
+ * no days in it at all. A caller gets either a complete object or null, and
22
+ * null means the page renders exactly as well as it did before this section
23
+ * existed.
24
+ */
25
+ export interface Departure {
26
+ // local clock time at the origin, as the row prints it
27
+ time: string
28
+ arrival: string | null
29
+ // days between boarding and arriving, 0 same day
30
+ offset: number
31
+ // already spelled out - "9h", "8h 30m"
32
+ duration: string | null
33
+ operator: string | null
34
+ // the formatted fare the cell prints
35
+ price: string | null
36
+ // the same fare as a number, for the Offer
37
+ amount: number | null
38
+ seats: number | null
39
+ url: string | null
40
+ // ISO 8601 instants, for the markup AND for the datetime attributes the
41
+ // cells carry - so the two cannot drift
42
+ departs: string | null
43
+ arrives: string | null
44
+ }
45
+
46
+ export interface Day {
47
+ // ISO, for the datetime attribute on the day heading
48
+ date: string
49
+ // "Sun, 20 September", in the reader's language
50
+ label: string
51
+ // empty on a day nothing runs
52
+ departures: Departure[]
53
+ }
54
+
55
+ export interface Prepared {
56
+ from: string
57
+ to: string
58
+ currency: string | null
59
+ days: Day[]
60
+ // "Fares and seats checked: ...", or null when generated_at is unusable
61
+ updated: string | null
62
+ // the same instant, unformatted, for Offer.validFrom - NON-NULL ONLY WHEN
63
+ // `updated` is, so the markup can never carry a timestamp the page does
64
+ // not print
65
+ quoted: string | null
66
+ }
67
+
68
+ /**
69
+ * At or below this many seats, the count is emphasised.
70
+ *
71
+ * Edited: Claude - Date: 2026-09-16
72
+ *
73
+ * THE SAME FIVE the search results use - see LOW_SEATS in
74
+ * ../Found/Route/Route.tsx, where the reasoning lives (a coach carries
75
+ * roughly fifty, so single figures are the point at which the number
76
+ * changes what somebody does about it rather than merely describing the
77
+ * bus). It is also the number the BusTrip markup turns into
78
+ * LimitedAvailability, so a coach that reads "nearly full" here says so to
79
+ * a machine as well.
80
+ *
81
+ * DUPLICATED, and that is a deliberate, temporary cost: the result card's
82
+ * copy is module-private and its file is being edited elsewhere as this is
83
+ * written. Worth folding into one exported constant the moment both are
84
+ * settled - two fives in two files is one edit away from a coach that reads
85
+ * "nearly full" on the search results and "plenty of room" here.
86
+ */
87
+ export const LOW_SEATS = 5;
88
+
89
+ const CLOCK = /^(\d{1,2}):(\d{2})/;
90
+
91
+ const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
92
+
93
+ const pad = (value: number): string => (value < 10 ? `0${ value }` : String(value));
94
+
95
+ /**
96
+ * What a zone's clocks read against UTC at a given instant, "+03:00".
97
+ *
98
+ * Edited: Claude - Date: 2026-09-16
99
+ *
100
+ * Read from Intl rather than carried in the payload, because the offset is
101
+ * a property of the DATE as much as of the zone: a 30-day window that
102
+ * crosses the last Sunday in October contains departures at +03:00 and
103
+ * departures at +02:00, and one number stamped on the whole response would
104
+ * be wrong for half of them.
105
+ *
106
+ * Null on anything unexpected - an engine without `longOffset`, a zone name
107
+ * the browser does not know - and the caller then falls back to a local
108
+ * datetime with no offset at all, which is still valid ISO 8601 and still
109
+ * exactly the clock time the page prints. A GUESSED offset would not be.
110
+ */
111
+ const zoned = (at: Date, zone: string): (string | null) => {
112
+ try {
113
+ const name = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' })
114
+ .formatToParts(at)
115
+ .find(part => part.type === 'timeZoneName')
116
+ ?.value;
117
+
118
+ if (!name) {
119
+ return null;
120
+ }
121
+
122
+ // Iceland and friends format as a bare "GMT" rather than "GMT+00:00"
123
+ if (name === 'GMT' || name === 'UTC') {
124
+ return '+00:00';
125
+ }
126
+
127
+ const parts = /^(?:GMT|UTC)([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(name);
128
+
129
+ return parts
130
+ ? `${ parts[1] }${ pad(Number(parts[2])) }:${ parts[3] ?? '00' }`
131
+ : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ };
136
+
137
+ /**
138
+ * A date and a clock time in a zone, as one ISO 8601 instant.
139
+ *
140
+ * Edited: Claude - Date: 2026-09-16
141
+ *
142
+ * `plus` is the overnight marker: an arrival with an offset of 1 lands on
143
+ * the following date, which is the whole reason the payload carries the
144
+ * number instead of a second date string.
145
+ *
146
+ * TWO PASSES over the offset, deliberately. The wall-clock time is turned
147
+ * into a provisional instant, the zone is asked what it was doing THEN, and
148
+ * the question is asked again at the instant that implies. One pass is
149
+ * wrong for the couple of hours either side of a clock change - it would
150
+ * stamp an 02:30 departure on the last Sunday in March with the offset that
151
+ * ends at 02:00 - and a bus leaving at the wrong hour is precisely the
152
+ * thing this section exists to get right.
153
+ */
154
+ export const instant = (date: string, time: string, zone: string, plus = 0): (string | null) => {
155
+ const day = DATE.exec(date);
156
+
157
+ const clock = CLOCK.exec(time);
158
+
159
+ if (!day || !clock) {
160
+ return null;
161
+ }
162
+
163
+ // Date.UTC rolls the month and the year over for us, so an overnight
164
+ // arrival on the 31st lands on the 1st without any calendar arithmetic
165
+ const wall = new Date(Date.UTC(
166
+ Number(day[1]),
167
+ Number(day[2]) - 1,
168
+ Number(day[3]) + plus,
169
+ Number(clock[1]),
170
+ Number(clock[2])
171
+ ));
172
+
173
+ if (Number.isNaN(wall.getTime())) {
174
+ return null;
175
+ }
176
+
177
+ const stamp = `${ wall.getUTCFullYear() }-${ pad(wall.getUTCMonth() + 1) }-${ pad(wall.getUTCDate()) }`
178
+ + `T${ pad(wall.getUTCHours()) }:${ pad(wall.getUTCMinutes()) }:00`;
179
+
180
+ const first = zoned(wall, zone);
181
+
182
+ if (!first) {
183
+ return stamp;
184
+ }
185
+
186
+ const minutes = (Number(first.slice(1, 3)) * 60) + Number(first.slice(4, 6));
187
+
188
+ const real = new Date(wall.getTime() - (first.startsWith('-') ? -minutes : minutes) * 60000);
189
+
190
+ return `${ stamp }${ zoned(real, zone) ?? first }`;
191
+ };
192
+
193
+ /**
194
+ * "Sun, 20 September", from the day and month names the site already ships
195
+ * in all fifteen languages.
196
+ *
197
+ * Three interpolations rather than one joined string, so a locale that
198
+ * orders them differently can - the same reason ../Facts/service spells out
199
+ * the date a route last ran.
200
+ *
201
+ * Null on anything that is not an ISO date, and the day is then dropped
202
+ * entirely rather than headed with a half-parsed one.
203
+ */
204
+ export const dated = (date: string, t: TFunction<'common'>): (string | null) => {
205
+ const parts = DATE.exec(date);
206
+
207
+ if (!parts) {
208
+ return null;
209
+ }
210
+
211
+ const at = new Date(Date.UTC(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])));
212
+
213
+ if (Number.isNaN(at.getTime())) {
214
+ return null;
215
+ }
216
+
217
+ const month = MONTHS[Number(parts[2]) - 1];
218
+
219
+ // getUTCDay is 0 for Sunday; WEEKDAYS is keyed ISO, 1 Monday to 7 Sunday
220
+ const weekday = WEEKDAYS[at.getUTCDay() === 0 ? 7 : at.getUTCDay()];
221
+
222
+ if (!month || !weekday) {
223
+ return null;
224
+ }
225
+
226
+ return t('routes_order.availability.date', {
227
+ weekday: t(`data.days.${ weekday }`),
228
+ day: String(Number(parts[3])),
229
+ month: t(`data.months.${ month }`)
230
+ });
231
+ };
232
+
233
+ /**
234
+ * When the seats and fares below were measured.
235
+ *
236
+ * Edited: Claude - Date: 2026-09-16
237
+ *
238
+ * A seat count is only true of a moment, and this section is the one place
239
+ * on the site that prints one out of a booking flow - so it says which
240
+ * moment. It is also what earns the Offer its `validFrom`: the markup may
241
+ * carry that timestamp precisely because the page prints it.
242
+ *
243
+ * Shown in the ORIGIN's zone, not the reader's. Every other time in the
244
+ * table is local to the departure city, and a footnote that silently
245
+ * switched to the reader's own clock would be the one line on the page
246
+ * measured differently from the rest of it.
247
+ */
248
+ export const checked = (stamp: string, zone: string, t: TFunction<'common'>): (string | null) => {
249
+ const at = new Date(stamp);
250
+
251
+ if (Number.isNaN(at.getTime())) {
252
+ return null;
253
+ }
254
+
255
+ try {
256
+ const parts = new Intl.DateTimeFormat('en-GB', {
257
+ timeZone: zone,
258
+ year: 'numeric',
259
+ month: 'numeric',
260
+ day: 'numeric',
261
+ hour: '2-digit',
262
+ minute: '2-digit',
263
+ hour12: false
264
+ }).formatToParts(at);
265
+
266
+ const value = (type: string): string => (parts.find(part => part.type === type)?.value ?? '');
267
+
268
+ const month = MONTHS[Number(value('month')) - 1];
269
+
270
+ if (!month || !value('year')) {
271
+ return null;
272
+ }
273
+
274
+ return t('routes_order.availability.updated', {
275
+ day: String(Number(value('day'))),
276
+ month: t(`data.months.${ month }`),
277
+ year: value('year'),
278
+ // hour12: false renders midnight as "24" in some engines
279
+ time: `${ value('hour') === '24' ? '00' : value('hour') }:${ value('minute') }`
280
+ });
281
+ } catch {
282
+ return null;
283
+ }
284
+ };
285
+
286
+ /**
287
+ * The whole section's data, or nothing at all.
288
+ *
289
+ * Edited: Claude - Date: 2026-09-16
290
+ *
291
+ * NOTHING AT ALL IS THE IMPORTANT CASE. The endpoint may not answer - it
292
+ * may not exist yet, it may time out, it may answer with a window in which
293
+ * nothing runs - and in every one of those the page has to be exactly as
294
+ * good as it was before this section was written. No empty box, no "we
295
+ * could not load departures", no heading over a blank table: a visitor who
296
+ * never knew this section was coming must not be shown its absence.
297
+ *
298
+ * A window in which NOTHING runs on any of the thirty days returns null
299
+ * too. A day with no departures is information - it sits in the table
300
+ * saying so, next to the days that do run - but thirty consecutive rows of
301
+ * "no departures" is not a timetable, it is a wall, and the facts block
302
+ * above already says plainly when a pair has stopped running.
303
+ */
304
+ export const prepare = (response: (AvailabilityResponse | undefined), t: TFunction<'common'>): (Prepared | null) => {
305
+ const data = response?.availability;
306
+
307
+ if (!data || !data.from || !data.to || !Array.isArray(data.days)) {
308
+ return null;
309
+ }
310
+
311
+ const zone = typeof data.timezone === 'string' ? data.timezone : '';
312
+
313
+ // Edited: Claude - Date: 2026-09-16
314
+ // The far end keeps its own clock; see arrival_timezone in ./types. Falls
315
+ // back to the departure zone, which is what this did before the API sent
316
+ // it - wrong only by the border, and never more wrong than that.
317
+ const arrivalZone = typeof data.arrival_timezone === 'string' && data.arrival_timezone
318
+ ? data.arrival_timezone
319
+ : zone;
320
+
321
+ const days = data.days.reduce<Day[]>((all, item) => {
322
+ const label = dated(item.date, t);
323
+
324
+ if (!label) {
325
+ return all;
326
+ }
327
+
328
+ const departures = (Array.isArray(item.departures) ? item.departures : []).reduce<Departure[]>((rows, row) => {
329
+ // a departure with no clock time is not a departure anybody can use
330
+ if (!row || typeof row.departure !== 'string' || !CLOCK.test(row.departure)) {
331
+ return rows;
332
+ }
333
+
334
+ const offset = Math.max(0, Number(row.arrival_offset ?? 0)) || 0;
335
+
336
+ return [ ...rows, {
337
+ time: row.departure,
338
+ arrival: row.arrival ?? null,
339
+ offset,
340
+ duration: typeof row.duration_min === 'number' ? spell(row.duration_min, t) : null,
341
+ operator: row.operator ?? null,
342
+ price: row.price_display ?? null,
343
+ amount: typeof row.price === 'number' ? row.price : null,
344
+ seats: typeof row.seats_left === 'number' ? row.seats_left : null,
345
+ url: row.url ?? null,
346
+ departs: instant(item.date, row.departure, zone),
347
+ arrives: row.arrival ? instant(item.date, row.arrival, arrivalZone, offset) : null
348
+ } ];
349
+ }, []);
350
+
351
+ return [ ...all, { date: item.date, label, departures } ];
352
+ }, []);
353
+
354
+ if (!days.some(day => day.departures.length > 0)) {
355
+ return null;
356
+ }
357
+
358
+ const updated = typeof data.generated_at === 'string' ? checked(data.generated_at, zone, t) : null;
359
+
360
+ return {
361
+ from: data.from,
362
+ to: data.to,
363
+ currency: data.currency ?? null,
364
+ days,
365
+ updated,
366
+ quoted: updated ? data.generated_at : null
367
+ };
368
+ };
@@ -0,0 +1,48 @@
1
+ import { UseQueryResult, useQuery } from '@tanstack/react-query';
2
+ import { apiClient } from '@autobusal/providers';
3
+ import { AvailabilityResponse } from './types';
4
+
5
+ /**
6
+ * How far ahead the section looks.
7
+ *
8
+ * Edited: Claude - Date: 2026-09-16
9
+ *
10
+ * Thirty, because that is the horizon somebody actually plans a coach
11
+ * journey on and it is the window an assistant is asked about ("is there a
12
+ * bus on the 20th"). It goes to the API as a parameter rather than being
13
+ * the endpoint's private opinion, so this number and the number of day
14
+ * blocks rendered below can never disagree.
15
+ */
16
+ export const DAYS = 30;
17
+
18
+ /**
19
+ * Real departures, on real dates.
20
+ *
21
+ * Edited: Claude - Date: 2026-09-16
22
+ *
23
+ * A SEPARATE request from useGetFacts, which is a cost worth naming: the
24
+ * pair page now makes two calls where it made one, on every page of the
25
+ * sitemap the prerender crawl walks. It is not avoidable - see ./types for
26
+ * why the two payloads cannot share a cache - and it is bounded, because
27
+ * this is one query per pair page, not one per departure.
28
+ *
29
+ * NO retry policy of its own. The client-wide one (providers/Queries)
30
+ * already does the right thing here: a 404, which is exactly what this
31
+ * answers with until the endpoint ships, is a decided answer and fails
32
+ * immediately without spending three attempts and two seconds on it.
33
+ */
34
+ export const useGetAvailability = (from?: string, to?: string): UseQueryResult<AvailabilityResponse> => (
35
+ useQuery({
36
+ queryKey: ['route-availability', from, to],
37
+ queryFn: async () => (
38
+ await apiClient
39
+ .get('/api/routes/search/availability', {
40
+ params: { from, to, days: DAYS }
41
+ })
42
+ .then(response => (
43
+ response.data
44
+ ))
45
+ ),
46
+ enabled: Boolean(from && to)
47
+ })
48
+ );