@autobusal/routes-order 1.37.5 → 1.37.7

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,127 @@
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 TRIPS = 100;
40
+
41
+ const Schema = ({ data, t }: Props): (JSX.Element | null) => {
42
+ const stop = (name: string) => ({ '@type': 'BusStop', name });
43
+
44
+ /**
45
+ * HOW MANY DEPARTURES GET MARKUP, which is not the same as how many the
46
+ * page shows.
47
+ *
48
+ * Edited: Claude - Date: 2026-09-16
49
+ *
50
+ * MEASURED on a busy pair (Tirana-Thessaloniki, ten coaches a day): thirty
51
+ * days produced 300 BusTrip nodes and 194 KB of JSON-LD - 41% of a 482 KB
52
+ * page, for a hundred near-identical descriptions of the same two-hour
53
+ * journey at different clock times. The table underneath still lists every
54
+ * one of the thirty days in plain text, so nothing is hidden from a reader
55
+ * or from a crawler; what is capped is the repetition in the markup.
56
+ *
57
+ * A hundred is roughly a fortnight on the busiest pair here and the whole
58
+ * month on most of them, which is the horizon anybody books within.
59
+ */
60
+ const trips = data.days.flatMap(day => day.departures.map(row => ({
61
+ '@type': 'BusTrip',
62
+ name: t('routes_order.facts.title', { from: data.from, to: data.to }),
63
+
64
+ departureBusStop: stop(data.from),
65
+ arrivalBusStop: stop(data.to),
66
+
67
+ // Real instants - date, clock time, and the offset of the city whose
68
+ // clock that time is read from: the origin's for the departure, the
69
+ // destination's for the arrival (see arrival_timezone in ./types). An overnight service's arrival is already on the following day;
70
+ // the payload's arrival_offset is what makes that knowable from two
71
+ // clock times. Absent rather than approximated when the date or the
72
+ // zone could not be resolved.
73
+ ...(row.departs ? { departureTime: row.departs } : {}),
74
+ ...(row.arrives ? { arrivalTime: row.arrives } : {}),
75
+
76
+ ...(row.operator ? {
77
+ provider: { '@type': 'Organization', name: row.operator }
78
+ } : {}),
79
+
80
+ // Only with a real ISO 4217 code and a real number: `price` formatted
81
+ // for display has the symbol baked in, and an Offer built by guessing a
82
+ // currency back out of it would be confidently wrong.
83
+ ...(row.amount !== null && data.currency ? {
84
+ offers: {
85
+ '@type': 'Offer',
86
+ price: row.amount,
87
+ priceCurrency: data.currency,
88
+
89
+ ...(row.url ? { url: row.url } : {}),
90
+
91
+ /*
92
+ * READ off the seat count the row prints, never asserted. Below the
93
+ * threshold that makes the cell bold, this says LimitedAvailability
94
+ * - the same claim, to the same standard, from the same number. A
95
+ * departure whose seat count is unknown is InStock, which is what
96
+ * listing it as bookable on a date already says.
97
+ */
98
+ availability: (row.seats !== null && row.seats <= LOW_SEATS)
99
+ ? 'https://schema.org/LimitedAvailability'
100
+ : 'https://schema.org/InStock',
101
+
102
+ /*
103
+ * WHEN THE FARE AND THE SEATS WERE MEASURED. Non-null only when the
104
+ * page prints that moment in its own footnote - see `checked` in
105
+ * ./service, where the two are tied together so that this cannot
106
+ * carry a timestamp a reader has no way to see.
107
+ */
108
+ ...(data.quoted ? { validFrom: data.quoted } : {}),
109
+
110
+ /*
111
+ * And when the offer stops being one: a seat cannot be bought after
112
+ * the coach has left. The instant is the departure the row shows,
113
+ * not a separate claim about a sales deadline.
114
+ */
115
+ ...(row.departs ? { availabilityEnds: row.departs } : {})
116
+ }
117
+ } : {})
118
+ })));
119
+
120
+ if (trips.length === 0) {
121
+ return null;
122
+ }
123
+
124
+ return <JsonLd data={ { '@context': 'https://schema.org', '@graph': trips.slice(0, TRIPS) } } />;
125
+ };
126
+
127
+ 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
+ );
@@ -0,0 +1,282 @@
1
+ import styled from 'styled-components';
2
+
3
+ export const Container = styled.section`
4
+ margin-top: 25px;
5
+ `;
6
+
7
+ export const Heading = styled.h2`
8
+ margin: 0 0 8px;
9
+ `;
10
+
11
+ /**
12
+ * One sentence saying what the table underneath is.
13
+ *
14
+ * Edited: Claude - Date: 2026-09-16
15
+ *
16
+ * Written for the reader who arrived asking whether there is a bus on a
17
+ * particular day - and, just as much, for the assistant answering that
18
+ * question on their behalf. A table of numbers with no sentence around it
19
+ * is perfectly readable to a person looking at it and gives a machine
20
+ * quoting the page nothing to quote.
21
+ */
22
+ export const Lead = styled.p`
23
+ margin: 0 0 15px;
24
+ color: ${ props => props.theme.font.faded };
25
+ line-height: 1.6;
26
+ `;
27
+
28
+ /**
29
+ * Sideways scrolling is confined to the table, never the page.
30
+ */
31
+ export const Wrap = styled.div`
32
+ overflow-x: auto;
33
+ `;
34
+
35
+ /**
36
+ * A real table, for the same reason the timetable above is one: this is
37
+ * tabular data, and the structure that makes it readable is the structure
38
+ * that lets a crawler understand the columns.
39
+ *
40
+ * Edited: Claude - Date: 2026-09-16
41
+ *
42
+ * Grouped by date, with the date as a row heading spanning the width -
43
+ * thirty separate tables would repeat the column headers thirty times, and
44
+ * a flat list of two hundred rows with the date in a seventh column is
45
+ * unreadable at exactly the moment somebody is scanning for one day.
46
+ *
47
+ * NO INTERACTIVITY ANYWHERE IN IT. A prerendered snapshot shows a
48
+ * non-JavaScript crawler precisely what a reader sees.
49
+ */
50
+ export const Table = styled.table`
51
+ width: 100%;
52
+ min-width: 620px;
53
+ border-collapse: collapse;
54
+
55
+ th, td {
56
+ padding: 10px 12px;
57
+ text-align: left;
58
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
59
+ white-space: nowrap;
60
+ }
61
+
62
+ thead th {
63
+ font-size: ${ props => props.theme.size.s };
64
+ color: ${ props => props.theme.font.faded };
65
+ font-weight: 400;
66
+ }
67
+
68
+ /*
69
+ * The date, as a heading row. Sticky-looking rather than sticky: a
70
+ * position: sticky heading inside a table fights the sticky section nav
71
+ * at the top of the page, and the two overlapping is worse than neither.
72
+ */
73
+ tbody th {
74
+ padding: 16px 12px 8px;
75
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
76
+ font-size: ${ props => props.theme.size.s };
77
+ font-weight: 700;
78
+ background: ${ props => props.theme.background.normal };
79
+ }
80
+
81
+ /* a day nothing runs on - stated, never omitted */
82
+ tbody td.none {
83
+ color: ${ props => props.theme.font.faded };
84
+ }
85
+
86
+ tbody tr:last-child td {
87
+ border-bottom: none;
88
+ }
89
+
90
+ /*
91
+ * The same reasoning as the timetable's own narrow-screen rule: six
92
+ * columns need 620px and the phone reading this has 375, so the columns
93
+ * that fall off the right edge would be the ones that matter most.
94
+ *
95
+ * Duration is the one that goes, and it is the right one: it is DERIVED
96
+ * from the departure and arrival already on the row, so a reader loses
97
+ * arithmetic rather than a fact. Everything else stays, including the
98
+ * seat count, which is the whole reason this section exists.
99
+ */
100
+ @media (max-width: 767px) {
101
+ min-width: 0;
102
+ table-layout: fixed;
103
+
104
+ th:nth-child(3), td:nth-child(3) {
105
+ display: none;
106
+ }
107
+
108
+ th, td {
109
+ padding: 8px 4px;
110
+ font-size: ${ props => props.theme.size.xs };
111
+ }
112
+
113
+ /* the times, the fare and the link take what they need; the operator
114
+ takes the remainder and wraps inside it */
115
+ th:nth-child(1), td:nth-child(1),
116
+ th:nth-child(2), td:nth-child(2) {
117
+ width: 17%;
118
+ }
119
+
120
+ th:nth-child(5), td:nth-child(5) {
121
+ width: 23%;
122
+ }
123
+
124
+ th:nth-child(6), td:nth-child(6) {
125
+ width: 17%;
126
+ }
127
+
128
+ th:nth-child(4), td:nth-child(4),
129
+ td.none {
130
+ white-space: normal;
131
+ word-break: break-word;
132
+ }
133
+ }
134
+ `;
135
+
136
+ /**
137
+ * A clock time that also carries the machine-readable instant.
138
+ *
139
+ * Edited: Claude - Date: 2026-09-16
140
+ *
141
+ * A <time datetime> rather than a <strong>, and the attribute is the very
142
+ * string the BusTrip markup emits - so the page does not merely happen to
143
+ * agree with its own structured data, it renders it.
144
+ */
145
+ export const Time = styled.time`
146
+ font-weight: 700;
147
+ `;
148
+
149
+ export const Faded = styled.span`
150
+ color: ${ props => props.theme.font.faded };
151
+ `;
152
+
153
+ /**
154
+ * The fare, with the seat count under it - the arrangement the result
155
+ * cards already use, because how many are left is a fact about this fare on
156
+ * this coach and is read at the same moment as the price.
157
+ */
158
+ export const Fare = styled.div`
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: 2px;
162
+ `;
163
+
164
+ export const Price = styled.strong`
165
+ font-weight: 700;
166
+ white-space: nowrap;
167
+ `;
168
+
169
+ /**
170
+ * How many seats are still on sale.
171
+ *
172
+ * Edited: Claude - Date: 2026-09-16
173
+ *
174
+ * The same string and the same threshold as the result cards - see
175
+ * SeatsLeft in ../Found/Route/styles and LOW_SEATS in ./service, where the
176
+ * reasoning lives. The component is not shared because the container is
177
+ * not: there it
178
+ * is centred under a fare in a card, here it is left-aligned in a table
179
+ * cell, and a shared component carrying both would be two components in a
180
+ * trench coat.
181
+ *
182
+ * What IS shared is the only thing that matters - the wording and the
183
+ * number - because a coach that reads "nearly full" on the search results
184
+ * and "plenty of room" here is a bug, and one that nobody would notice
185
+ * until a customer did.
186
+ */
187
+ export const SeatsLeft = styled.span<{ $low: boolean }>`
188
+ font-size: ${ props => props.theme.size.xxs };
189
+ line-height: 1.25;
190
+ white-space: nowrap;
191
+ font-weight: ${ props => (props.$low ? 700 : 400) };
192
+ color: ${ props => (props.$low ? props.theme.font.error : props.theme.font.faded) };
193
+ `;
194
+
195
+ /**
196
+ * The booking link for one departure.
197
+ *
198
+ * Edited: Claude - Date: 2026-09-16
199
+ *
200
+ * A plain anchor, not a router Link. The URL is built by obtapi and arrives
201
+ * absolute, carrying the date, the direction and its tracking parameters;
202
+ * picking it apart into a router path would mean re-deriving on the client
203
+ * something the server already decided, and getting it subtly wrong on a
204
+ * whitelabel domain. Same tab, because it is our own site and the reader is
205
+ * continuing, not leaving.
206
+ */
207
+ export const Book = styled.a`
208
+ display: inline-block;
209
+ padding: 6px 12px;
210
+ border-radius: ${ props => props.theme.borderRadius };
211
+ background: ${ props => props.theme.primary.neutral };
212
+ color: ${ props => props.theme.font.normal };
213
+ font-size: ${ props => props.theme.size.xs };
214
+ font-weight: 700;
215
+ white-space: nowrap;
216
+
217
+ &:hover {
218
+ text-decoration: none;
219
+ background: ${ props => props.theme.background.neutral };
220
+ }
221
+ `;
222
+
223
+ /**
224
+ * Everything past the first week.
225
+ *
226
+ * Edited: Claude - Date: 2026-09-16
227
+ *
228
+ * A native <details>, for the one property that decides it: THE ROWS ARE IN
229
+ * THE DOM WHETHER IT IS OPEN OR SHUT. This page is snapshot by the
230
+ * prerender crawl and read by machines that do not click, and the whole
231
+ * point of the section is that all thirty days are visible to them. An
232
+ * accordion built out of useState would ship a crawler seven days and a
233
+ * button - which is exactly how the homepage FAQ came to be prerendered
234
+ * with zero answers in it - while the BusTrip markup alongside promised
235
+ * thirty days of departures.
236
+ *
237
+ * Collapsed rather than not, because thirty day-blocks between the
238
+ * timetable and the footer is a page nobody reaches the bottom of.
239
+ */
240
+ export const Later = styled.details`
241
+ margin-top: 4px;
242
+
243
+ &[open] summary {
244
+ margin-bottom: 4px;
245
+ }
246
+ `;
247
+
248
+ export const More = styled.summary`
249
+ display: inline-flex;
250
+ align-items: center;
251
+ gap: 8px;
252
+ padding: 10px 0;
253
+ cursor: pointer;
254
+ font-size: ${ props => props.theme.size.s };
255
+ font-weight: 700;
256
+ list-style: none;
257
+
258
+ /* the default disclosure triangle, replaced below by one that turns */
259
+ &::-webkit-details-marker {
260
+ display: none;
261
+ }
262
+
263
+ &:after {
264
+ content: '+';
265
+ color: ${ props => props.theme.font.faded };
266
+ font-weight: 400;
267
+ }
268
+
269
+ [open] > &:after {
270
+ content: '-';
271
+ }
272
+ `;
273
+
274
+ /**
275
+ * When the seats and fares above were measured - see `checked` in
276
+ * ./service for why the page says so at all.
277
+ */
278
+ export const Updated = styled.p`
279
+ margin: 12px 0 0;
280
+ font-size: ${ props => props.theme.size.xs };
281
+ color: ${ props => props.theme.font.faded };
282
+ `;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What /api/routes/search/availability answers with.
3
+ *
4
+ * Edited: Claude - Date: 2026-09-16
5
+ *
6
+ * A SECOND endpoint, deliberately, rather than another branch of
7
+ * /facts. The facts payload is cached for six hours because everything in
8
+ * it - the duration range, the fares-from, the operators - is a property of
9
+ * the TIMETABLE and does not change between two visitors. A seat count is
10
+ * the opposite: it is true at the moment it was measured and false by the
11
+ * afternoon, and folding it into a payload with a six-hour cache would have
12
+ * published stale seat counts as structured data.
13
+ *
14
+ * The nullable fields mirror ScheduleRow in ../Facts/types for the same
15
+ * reason it has them: this arrives over the wire from a search that can
16
+ * legitimately not know an operator name or a formatted fare, and a page
17
+ * that renders "undefined" in a timetable cell is worse than one that
18
+ * renders a dash.
19
+ */
20
+ export interface AvailabilityDeparture {
21
+ // local clock time at the origin, "08:30"
22
+ departure: string
23
+ arrival: string | null
24
+ /**
25
+ * Days between boarding and arriving - 0 same day, 1 the next morning.
26
+ * Optional because an older build of the endpoint may not send it, and
27
+ * absent has to read as "same day" rather than as a crash.
28
+ */
29
+ arrival_offset?: number
30
+ duration_min: number | null
31
+ operator: string | null
32
+ // how many intermediate calls - carried for completeness; this section
33
+ // does not show it, and the timetable below already does
34
+ stops: number
35
+ price: number | null
36
+ price_display: string | null
37
+ // null where the search cannot say - see the render, which then shows
38
+ // nothing rather than guessing at a number people act on
39
+ seats_left: number | null
40
+ // the booking URL for THIS departure, already carrying its date and its
41
+ // tracking parameters - built by obtapi, never assembled here
42
+ url: string | null
43
+ }
44
+
45
+ export interface AvailabilityDay {
46
+ // ISO date, "2026-09-20"
47
+ date: string
48
+ // empty on a day nothing runs, which is information rather than an
49
+ // omission - see Availability.tsx
50
+ departures: AvailabilityDeparture[]
51
+ }
52
+
53
+ export interface AvailabilityData {
54
+ from: string
55
+ to: string
56
+ from_slug: string
57
+ to_slug: string
58
+ // ISO 4217, for the Offer - price_display has the symbol baked in and
59
+ // guessing a code back out of it would be confidently wrong
60
+ currency: string | null
61
+ // IANA zone of the ORIGIN, which is what turns "08:30 on 20 September"
62
+ // into an instant a machine can compare against another operator's
63
+ timezone: string
64
+
65
+ /**
66
+ * The clock the ARRIVAL times are on, which is not the departure city's on
67
+ * exactly the journeys this section exists for.
68
+ *
69
+ * Edited: Claude - Date: 2026-09-16
70
+ *
71
+ * A timetable prints every stop in its own local time. Stamping an arrival
72
+ * with the origin's offset publishes an instant that is wrong by the
73
+ * difference between the two - Thessaloniki to Tirana arrives 16:30 in
74
+ * Tirana, an hour after the +03:00 reading of the same clock face - and in
75
+ * schema.org markup that is a claim an assistant repeats as fact.
76
+ *
77
+ * Optional: an older API does not send it, and the code falls back to the
78
+ * departure zone, which is exactly what it did before this existed.
79
+ */
80
+ arrival_timezone?: string
81
+ // when the seats and fares below were measured
82
+ generated_at: string
83
+ days: AvailabilityDay[]
84
+ }
85
+
86
+ export interface AvailabilityResponse {
87
+ // an envelope rather than a bare null, matching FactsResponse - `{}` is
88
+ // truthy in JavaScript and a bare null tells a caller nothing about
89
+ // whether the request was even understood
90
+ availability: AvailabilityData | null
91
+ }
package/Facts/service.ts CHANGED
@@ -41,8 +41,15 @@ export const stopped = (facts: FactsData): (Stopped | null) => {
41
41
  * ISO month index to the month names the site already ships in all fifteen
42
42
  * languages (`data.months.*`), rather than a new set nobody would have
43
43
  * translated for one sentence.
44
+ *
45
+ * Edited: Claude - Date: 2026-09-16
46
+ * Exported, because the next-30-days section names months too. Two copies
47
+ * of this list is two chances for one of them to be edited - and the whole
48
+ * value of leaning on `data.months.*` is that there is exactly one set of
49
+ * month names on the site, in fifteen languages, that somebody has already
50
+ * proof-read.
44
51
  */
45
- const MONTHS = [
52
+ export const MONTHS = [
46
53
  'january', 'february', 'march', 'april', 'may', 'june',
47
54
  'july', 'august', 'september', 'october', 'november', 'december'
48
55
  ];
@@ -97,11 +97,18 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
97
97
  * Typed rather than truthy-checked: 0 is a real answer from obtapi and it
98
98
  * means a full bus, which never reaches a result list - and a `!` test
99
99
  * would have hidden the field on exactly the coach it mattered most on if
100
- * one ever did. An external-provider offer (Mode A) carries no trip of
101
- * ours to count, so it simply shows nothing.
100
+ * one ever did. An external-provider offer (Mode A) carries no count of
101
+ * ours, so it simply shows nothing.
102
+ *
103
+ * Edited: Claude - Date: 2026-09-16
104
+ * `seats_left`, NOT `trip.available_seats`. The first version of this read
105
+ * the capacity column - the only seat number the payload had - and shipped
106
+ * it as "Seats left", which announces an empty coach on a sold-out one.
107
+ * The search has always worked out the real figure to decide whether to
108
+ * offer the departure; it just never sent it. Now it does.
102
109
  */
103
- const seats = typeof data.trip?.available_seats === 'number' && data.trip.available_seats > 0
104
- ? data.trip.available_seats
110
+ const seats = typeof data.seats_left === 'number' && data.seats_left > 0
111
+ ? data.seats_left
105
112
  : null;
106
113
 
107
114
  // Edited: Ferjolt Ozuni - Date: 2026-08-03
@@ -1,10 +1,14 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import Facts from '../Facts/Facts';
3
3
  import Faq from '../Faq/Faq';
4
+ import Availability from '../Availability/Availability';
5
+ import AvailabilitySchema from '../Availability/Schema';
4
6
  import Schedule from '../Schedule/Schedule';
5
7
  import Links from '../Links/Links';
6
8
  import Schema from './Schema';
7
9
  import { useGetFacts } from '../Facts/services';
10
+ import { useGetAvailability } from '../Availability/services';
11
+ import { prepare } from '../Availability/service';
8
12
  import buildQuestions from '../Facts/questions';
9
13
  import { stopped } from '../Facts/service';
10
14
  import { Nav, Anchor, Anchored } from './styles';
@@ -17,6 +21,7 @@ interface Props {
17
21
 
18
22
  const TRIPS = 'trips';
19
23
  const FACTS = 'facts';
24
+ const AVAILABILITY = 'availability';
20
25
  const SCHEDULE = 'schedule';
21
26
  const FAQ = 'faq';
22
27
  const LINKS = 'links';
@@ -36,12 +41,25 @@ const LINKS = 'links';
36
41
  * response, and letting each fetch its own would have doubled what the
37
42
  * prerender crawl costs on every pair in the sitemap.
38
43
  *
44
+ * Edited: Claude - Date: 2026-09-16
45
+ *
46
+ * TWO fetches now, and the second one is not a regression of the paragraph
47
+ * above. The next-30-days section needs live seat counts, and the facts
48
+ * payload is cached for six hours precisely because nothing in it moves -
49
+ * folding one into the other would either publish stale seat counts or
50
+ * throw away a cache that every pair page depends on. It stays one request
51
+ * per page rather than one per departure, and it renders NOTHING when it
52
+ * fails, so a page whose second call never answers is exactly the page it
53
+ * was yesterday.
54
+ *
39
55
  * The nav renders only when there is more than the wizard to navigate to -
40
56
  * a bar whose only link is "Trips" is furniture.
41
57
  */
42
58
  const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
43
59
  const { data } = useGetFacts(from, to);
44
60
 
61
+ const { data: dates } = useGetAvailability(from, to);
62
+
45
63
  const facts = data?.facts;
46
64
 
47
65
  const links = data?.links ?? { from: [], to: [], reverse: null };
@@ -66,6 +84,19 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
66
84
  // that happen to agree today.
67
85
  const questions = buildQuestions(facts, t);
68
86
 
87
+ /**
88
+ * Edited: Claude - Date: 2026-09-16
89
+ *
90
+ * Prepared once, here, and handed to the section AND to its structured
91
+ * data - the same arrangement `questions` above is in, for the same
92
+ * reason. Null when the endpoint did not answer, answered with nothing,
93
+ * or answered with a window in which no coach runs; every consumer below
94
+ * is gated on that single value, so there is no arrangement in which the
95
+ * nav offers a link to a section that rendered nothing, or the markup
96
+ * describes departures the page does not list.
97
+ */
98
+ const availability = prepare(dates, t);
99
+
69
100
  /**
70
101
  * Edited: Claude - Date: 2026-08-21
71
102
  *
@@ -108,6 +139,10 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
108
139
  <Anchor href={ `#${ TRIPS }` }>{ t('routes_order.sections.trips') }</Anchor>
109
140
  <Anchor href={ `#${ FACTS }` }>{ t('routes_order.sections.facts') }</Anchor>
110
141
 
142
+ { availability && (
143
+ <Anchor href={ `#${ AVAILABILITY }` }>{ t('routes_order.sections.availability') }</Anchor>
144
+ ) }
145
+
111
146
  { schedule.length > 0 && (
112
147
  <Anchor href={ `#${ SCHEDULE }` }>{ t('routes_order.sections.schedule') }</Anchor>
113
148
  ) }
@@ -122,12 +157,34 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
122
157
  show - same facts, same numbers, same operators. */ }
123
158
  <Schema facts={ facts } questions={ questions } t={ t } />
124
159
 
160
+ { /* Edited: Claude - Date: 2026-09-16
161
+ One BusTrip per dated departure, alongside the pair-level one
162
+ above rather than instead of it: that one describes the journey,
163
+ these describe the actual coaches on actual days. Built from the
164
+ same prepared array the section renders. */ }
165
+ { availability && <AvailabilitySchema data={ availability } t={ t } /> }
166
+
125
167
  <Anchored>
126
168
  <Facts id={ FACTS } facts={ facts } t={ t } />
127
169
  </Anchored>
128
170
 
129
171
  { dead && onward }
130
172
 
173
+ { /* Edited: Claude - Date: 2026-09-16
174
+ ABOVE the timetable, because a reader who has a date in mind is
175
+ answered here and a reader who does not is answered below. The
176
+ timetable is the pattern - what runs, in the abstract; this is
177
+ the next thirty days of it, with fares and seats. Renders nothing
178
+ at all when the endpoint gives us nothing, which is also what
179
+ happens on a pair that has stopped running: the notice in the
180
+ facts block above has already said so, and thirty rows of "no
181
+ departures" underneath it would be a wall, not an answer. */ }
182
+ { availability && (
183
+ <Anchored>
184
+ <Availability id={ AVAILABILITY } data={ availability } t={ t } />
185
+ </Anchored>
186
+ ) }
187
+
131
188
  <Anchored>
132
189
  <Schedule id={ SCHEDULE } from={ facts.from } to={ facts.to } rows={ schedule } t={ t } />
133
190
  </Anchored>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.37.5",
3
+ "version": "1.37.7",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts",