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