@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.
- package/Availability/Availability.tsx +166 -0
- package/Availability/Schema.tsx +127 -0
- package/Availability/service.ts +368 -0
- package/Availability/services.ts +48 -0
- package/Availability/styles.ts +282 -0
- package/Availability/types.ts +91 -0
- package/Facts/service.ts +8 -1
- package/Found/Route/Route.tsx +11 -4
- package/Sections/Sections.tsx +57 -0
- package/package.json +1 -1
|
@@ -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
|
];
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -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
|
|
101
|
-
* ours
|
|
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.
|
|
104
|
-
? data.
|
|
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
|
package/Sections/Sections.tsx
CHANGED
|
@@ -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>
|