@autobusal/routes-order 1.37.8 → 1.37.9

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.
@@ -17,8 +17,15 @@ interface Props {
17
17
  *
18
18
  * A week: enough that somebody asking "is there a bus this weekend" never
19
19
  * has to expand anything, few enough that the page still ends.
20
+ *
21
+ * Edited: Claude - Date: 2026-09-16
22
+ *
23
+ * Exported now: ./Skeleton reserves height for exactly this many day-blocks,
24
+ * not all thirty, because days 8-30 sit inside the closed <details> below
25
+ * and reserve nothing themselves. One number, so the skeleton and the real
26
+ * table can never disagree about how many days are "above the fold".
20
27
  */
21
- const VISIBLE = 7;
28
+ export const VISIBLE = 7;
22
29
 
23
30
  /**
24
31
  * Real departures on real dates, for the next thirty days.
@@ -0,0 +1,119 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Bone } from '../styles';
3
+ import { Container, Heading, Lead, Wrap, Table } from './styles';
4
+ import { VISIBLE } from './Availability';
5
+
6
+ interface Props {
7
+ id: string
8
+ // Real city names where they are already known - see the call site in
9
+ // ../Sections/Sections, which has them from the SAME facts query this
10
+ // section's own heading is built from once it answers. Undefined only
11
+ // for the instant before that, in which case the heading is a bar rather
12
+ // than a guess at English words in the middle of another language.
13
+ from?: string
14
+ to?: string
15
+ t: TFunction<'common'>
16
+ }
17
+
18
+ const COLUMNS = 6;
19
+
20
+ /**
21
+ * How many rows a day's placeholder reserves.
22
+ *
23
+ * Edited: Claude - Date: 2026-09-16
24
+ *
25
+ * Not knowable before the fetch answers - a quiet rural pair might run once
26
+ * a day, a trunk route ten times (see TRIPS in ./Schema, measured on
27
+ * Tirana-Thessaloniki). Three is not a guess pulled from nowhere: three rows
28
+ * across the seven VISIBLE days is twenty-one, and at this table's real
29
+ * padding and line-height that comes to almost exactly the ~1,290px,
30
+ * ~20-row block that was actually measured on production - see the
31
+ * height math this number produces below, which is what earns it the name
32
+ * "defensible" rather than "round". Under-reserving still shifts the page
33
+ * when the real rows land; over-reserving by a lot wastes screen before
34
+ * anyone has asked for anything - three is the number that keeps both
35
+ * errors small.
36
+ */
37
+ const ROWS_PER_DAY = 3;
38
+
39
+ const day = (index: number): JSX.Element => (
40
+ <tbody key={ index }>
41
+ <tr>
42
+ <th colSpan={ COLUMNS } scope="colgroup">
43
+ <Bone $width={ 35 } $height={ 16 } />
44
+ </th>
45
+ </tr>
46
+
47
+ { Array.from({ length: ROWS_PER_DAY }, (_, row) => (
48
+ <tr key={ row }>
49
+ <td><Bone $width={ 70 } /></td>
50
+ <td><Bone $width={ 70 } /></td>
51
+ <td><Bone $width={ 60 } /></td>
52
+ <td><Bone $width={ 80 } /></td>
53
+ <td><Bone $width={ 55 } /></td>
54
+ <td><Bone $width={ 60 } /></td>
55
+ </tr>
56
+ )) }
57
+ </tbody>
58
+ );
59
+
60
+ /**
61
+ * What the section looks like before /api/routes/search/availability
62
+ * answers.
63
+ *
64
+ * Edited: Claude - Date: 2026-09-16
65
+ *
66
+ * WHY THIS EXISTS. This section used to render nothing at all while the
67
+ * fetch was in flight (~1.2-1.4s) and then drop a ~1,290px block in all at
68
+ * once - measured CLS of 0.5172-1.1646 on pair pages, against a homepage
69
+ * (which has no such block) of 0.0000. The fix is not to load faster, it is
70
+ * to occupy the space from the FIRST paint, in the shape the real content
71
+ * will take, so the swap moves nothing.
72
+ *
73
+ * SAME BOXES, PLACEHOLDER CONTENT. This reuses Availability's own Container,
74
+ * Heading, Lead, Wrap and Table rather than a hand-measured approximation of
75
+ * them, so the two can never quietly drift apart - a padding change in
76
+ * ./styles resizes both at once. Only the day count (VISIBLE, imported
77
+ * rather than repeated) and the row count (ROWS_PER_DAY, above) are guesses;
78
+ * everything else is the real box model.
79
+ *
80
+ * aria-hidden, and no live region: a placeholder table announces nothing,
81
+ * because it has nothing to announce - the section's actual content is
82
+ * either the real table that replaces this, a few seconds later, or nothing
83
+ * at all.
84
+ */
85
+ const AvailabilitySkeleton = ({ id, from, to, t }: Props): JSX.Element => (
86
+ <Container id={ id } className="box" aria-hidden="true">
87
+ <Heading>
88
+ { from && to
89
+ ? t('routes_order.availability.title', { from, to })
90
+ : <Bone $width={ 45 } $height={ 22 } /> }
91
+ </Heading>
92
+
93
+ { /* Not data-dependent - the same translation the real Lead prints,
94
+ so this line never has to move when the real table lands. */ }
95
+ <Lead>{ t('routes_order.availability.lead') }</Lead>
96
+
97
+ <Wrap>
98
+ <Table>
99
+ <thead>
100
+ <tr>
101
+ { /* Real column headers, not placeholders - like the lead
102
+ above, these come from static translations rather than
103
+ from the fetch, so there is no reason to guess at them. */ }
104
+ <th>{ t('routes_order.schedule.departure') }</th>
105
+ <th>{ t('routes_order.schedule.arrival') }</th>
106
+ <th>{ t('routes_order.schedule.duration') }</th>
107
+ <th>{ t('routes_order.schedule.operator') }</th>
108
+ <th>{ t('routes_order.availability.price') }</th>
109
+ <th>{ t('routes_order.availability.book') }</th>
110
+ </tr>
111
+ </thead>
112
+
113
+ { Array.from({ length: VISIBLE }, (_, index) => day(index)) }
114
+ </Table>
115
+ </Wrap>
116
+ </Container>
117
+ );
118
+
119
+ export default AvailabilitySkeleton;
@@ -185,7 +185,8 @@ export const Price = styled.strong`
185
185
  * until a customer did.
186
186
  */
187
187
  export const SeatsLeft = styled.span<{ $low: boolean }>`
188
- font-size: ${ props => props.theme.size.xxs };
188
+ // Claude - 2026-09-16 (audit): xs - the xxs was 10px, under the phone floor
189
+ font-size: ${ props => props.theme.size.xs };
189
190
  line-height: 1.25;
190
191
  white-space: nowrap;
191
192
  font-weight: ${ props => (props.$low ? 700 : 400) };
@@ -45,15 +45,28 @@ export const Toggle = styled.button<{ $open: boolean }>`
45
45
  z-index: 900;
46
46
 
47
47
  /*
48
- * Clear of the home indicator on a notched phone. env() resolves to 0 on
49
- * everything that has no inset, so the fallback is simply the base value.
48
+ * Clear of the home indicator on a notched phone, AND clear of the
49
+ * cookie notice on a narrow one.
50
+ *
51
+ * Edited: Claude - Date: 2026-09-16
52
+ *
53
+ * --obt-cookie-notice-height is published by CookieNotification
54
+ * (packages/common) while its own bar sits fixed to this same edge under
55
+ * 640px - see NOTICE_HEIGHT_VAR there. Two elements anchored to the same
56
+ * "bottom: 0" both being fixed to the viewport does not resolve itself by
57
+ * z-index, which only decides which one paints on top of the other; this
58
+ * button was measured sitting directly under the notice at 375px. The
59
+ * variable defaults to 0px, so a page without the notice - or a visitor
60
+ * who has already dismissed it - sees no change at all.
50
61
  */
51
- bottom: calc(20px + env(safe-area-inset-bottom, 0px));
62
+ bottom: calc(20px + env(safe-area-inset-bottom, 0px) + var(--obt-cookie-notice-height, 0px));
52
63
 
53
64
  display: flex;
54
65
  align-items: center;
55
66
  gap: 6px;
56
67
  padding: 9px 16px;
68
+ // Claude - 2026-09-16 (audit): the 44px tap minimum - this measured 35px
69
+ min-height: 44px;
57
70
  font-size: ${ props => props.theme.size.xs };
58
71
  font-weight: 700;
59
72
  border-radius: 100px;
@@ -1,7 +1,7 @@
1
1
  import { TFunction } from 'i18next';
2
- import { Inline } from '@autobusal/common';
3
2
  import Route from '../Route/Route';
4
3
  import Empty from '../Empty/Empty';
4
+ import Skeleton from './Skeleton';
5
5
  import { getFavorites, getNormal } from './utilities';
6
6
  import { sortItems, SortKey } from '../refine';
7
7
  import { Container, ContainerNotFound, Reset } from './styles';
@@ -27,12 +27,16 @@ interface Props {
27
27
  }
28
28
 
29
29
  const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferredStop, step1, alternatives, alternativesLoading, t, onReset, onSave }: Props): JSX.Element => {
30
+ /*
31
+ * Edited: Claude - Date: 2026-09-16
32
+ *
33
+ * A skeleton the shape of the real list, not a one-line message - see
34
+ * ./Skeleton for why (measured on a dated results URL, which is never
35
+ * prerendered: 0.6868-1.1646 CLS from a page that renders almost nothing
36
+ * until the search answers, then drops every card in at once).
37
+ */
30
38
  if (loading) {
31
- return (
32
- <div className="box">
33
- <Inline text={ t('routes_order.step2.loading.routes') } />
34
- </div>
35
- );
39
+ return <Skeleton t={ t } />;
36
40
  }
37
41
 
38
42
  // Edited: Ferjolt Ozuni - Date: 2026-08-01
@@ -0,0 +1,102 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Bone } from '../../styles';
3
+ import { Container as Card, Trip, Company, Location, Time, Price, Amount } from '../Route/styles';
4
+ import { Container } from './styles';
5
+
6
+ /**
7
+ * How many placeholder cards to reserve room for.
8
+ *
9
+ * Edited: Claude - Date: 2026-09-16
10
+ *
11
+ * Not knowable before the search answers - see ROWS_PER_DAY in
12
+ * ../../Availability/Skeleton for the same problem one section over. Five
13
+ * is the same kind of middle estimate: enough to fill roughly one screen on
14
+ * a desktop, which is where a reserved-but-empty block would otherwise be
15
+ * most visible, and not so many that a route with genuinely few departures
16
+ * reserves a page and a half of blank cards. A result count either side of
17
+ * five is a residual shift of one card's height, not the ~20-card collapse
18
+ * this exists to prevent.
19
+ */
20
+ const CARDS = 5;
21
+
22
+ /**
23
+ * One placeholder card, in the shape Route/Route.tsx renders.
24
+ *
25
+ * Edited: Claude - Date: 2026-09-16
26
+ *
27
+ * SAME STYLED COMPONENTS AS THE REAL CARD - Card, Trip, Company, Location,
28
+ * Time, Price, Amount all come from ../Route/styles, not a redrawn
29
+ * approximation of them. That is what keeps this accurate as the card
30
+ * itself changes: a padding or gap edited there resizes the skeleton at the
31
+ * same time, rather than the two silently drifting apart.
32
+ *
33
+ * The three bars inside Price carry the REAL card's own class names
34
+ * (price-notice, details-toggle, seats-left) so the same nth-child/class
35
+ * ordering rules in Price's CSS lay them out exactly as they lay out the
36
+ * real amount, tax note, button, details toggle and seat count - including
37
+ * the extra rows those last two add on a phone, which is most of what made
38
+ * the old three-line estimate come up short.
39
+ */
40
+ const SkeletonCard = (): JSX.Element => (
41
+ <Card className="box" $type="normal" aria-hidden="true">
42
+ <Trip>
43
+ <Company>
44
+ <Bone $width={ 45 } $height={ 28 } />
45
+ <Bone $width={ 60 } $height={ 14 } />
46
+ </Company>
47
+
48
+ <Location $side="from">
49
+ <Bone $width={ 75 } $height={ 17 } />
50
+ <Bone $width={ 90 } $height={ 15 } />
51
+ </Location>
52
+
53
+ <Time>
54
+ <Bone $width={ 40 } $height={ 30 } />
55
+ </Time>
56
+
57
+ <Location $side="to">
58
+ <Bone $width={ 75 } $height={ 17 } />
59
+ <Bone $width={ 90 } $height={ 15 } />
60
+ </Location>
61
+
62
+ <Price>
63
+ <Amount><Bone $width={ 60 } $height={ 26 } /></Amount>
64
+ <Bone $width={ 100 } $height={ 44 } />
65
+ <Bone className="price-notice" $width={ 85 } $height={ 14 } />
66
+ <Bone className="details-toggle" $width={ 55 } $height={ 16 } />
67
+ <Bone className="seats-left" $width={ 65 } $height={ 14 } />
68
+ </Price>
69
+ </Trip>
70
+ </Card>
71
+ );
72
+
73
+ interface Props {
74
+ t: TFunction<'common'>
75
+ }
76
+
77
+ /**
78
+ * What the results list looks like before a search answers.
79
+ *
80
+ * Edited: Claude - Date: 2026-09-16
81
+ *
82
+ * WHY THIS EXISTS. A dated results URL (?departure=...) is never
83
+ * prerendered - it serves the SPA shell, so the very first thing a visitor
84
+ * sees is whatever this component renders while `loading` is true. That
85
+ * used to be a single line of text ("Searching for tickets..."), which
86
+ * collapsed the whole page to almost nothing and then dropped every result
87
+ * card in at once the moment the search answered - the same shape of CLS
88
+ * bug as ../../Availability/Skeleton, on a page that cannot be prerendered
89
+ * around it.
90
+ *
91
+ * `role="status"` with the SAME loading string the old text used, rather
92
+ * than a live region per card: a screen reader is told once that results
93
+ * are loading, not read five identical placeholder cards. The cards
94
+ * themselves are aria-hidden - there is nothing in them to announce.
95
+ */
96
+ const Skeleton = ({ t }: Props): JSX.Element => (
97
+ <Container role="status" aria-label={ t('routes_order.step2.loading.routes') }>
98
+ { Array.from({ length: CARDS }, (_, index) => <SkeletonCard key={ index } />) }
99
+ </Container>
100
+ );
101
+
102
+ export default Skeleton;
@@ -94,7 +94,12 @@ export const Option = styled.button<{ $active: boolean }>`
94
94
  overflow: hidden;
95
95
  text-overflow: ellipsis;
96
96
  padding: 5px 2px;
97
- font-size: calc(${ props => props.theme.size.xs } - 2px);
97
+ // Claude - 2026-09-16 (audit): xs, not xs minus 2 - that was 10px, under
98
+ // the phone floor. (These pills are only ever painted at 640px and up -
99
+ // see Options in ./Sort.tsx - but the rule cascades to the hidden phone
100
+ // copy of every one of them regardless, and a text-size audit reads
101
+ // computed style, not display.)
102
+ font-size: ${ props => props.theme.size.xs };
98
103
  font-weight: 700;
99
104
  white-space: nowrap;
100
105
 
@@ -2,6 +2,7 @@ import { TFunction } from 'i18next';
2
2
  import Facts from '../Facts/Facts';
3
3
  import Faq from '../Faq/Faq';
4
4
  import Availability from '../Availability/Availability';
5
+ import AvailabilitySkeleton from '../Availability/Skeleton';
5
6
  import AvailabilitySchema from '../Availability/Schema';
6
7
  import Schedule from '../Schedule/Schedule';
7
8
  import Links from '../Links/Links';
@@ -60,7 +61,12 @@ const LINKS = 'links';
60
61
  const Sections = ({ from, to, heading, t }: Props): (JSX.Element | null) => {
61
62
  const { data } = useGetFacts(from, to);
62
63
 
63
- const { data: dates } = useGetAvailability(from, to);
64
+ // Edited: Claude - Date: 2026-09-16
65
+ // `isLoading`, not `isFetching`: this is the ONE state the skeleton below
66
+ // exists for - the first request, before anything has ever answered. A
67
+ // background refetch re-uses `dates` from cache while it runs, so it never
68
+ // sees this true and never swaps a real table back out for a placeholder.
69
+ const { data: dates, isLoading: datesLoading } = useGetAvailability(from, to);
64
70
 
65
71
  const facts = data?.facts;
66
72
 
@@ -180,11 +186,23 @@ const Sections = ({ from, to, heading, t }: Props): (JSX.Element | null) => {
180
186
  at all when the endpoint gives us nothing, which is also what
181
187
  happens on a pair that has stopped running: the notice in the
182
188
  facts block above has already said so, and thirty rows of "no
183
- departures" underneath it would be a wall, not an answer. */ }
184
- { availability && (
189
+ departures" underneath it would be a wall, not an answer.
190
+
191
+ Edited: Claude - Date: 2026-09-16
192
+ WHILE IT LOADS, a skeleton the same shape takes the space instead
193
+ of nothing - see Availability/Skeleton for why (measured CLS of
194
+ 0.52-1.16 on this exact block). `datesLoading` is the only other
195
+ branch: a pair that answers with nothing goes straight from the
196
+ skeleton to rendering nothing at all, which is correct - there
197
+ was never a table to reserve room for. */ }
198
+ { availability ? (
185
199
  <Anchored>
186
200
  <Availability id={ AVAILABILITY } data={ availability } t={ t } />
187
201
  </Anchored>
202
+ ) : datesLoading && (
203
+ <Anchored>
204
+ <AvailabilitySkeleton id={ AVAILABILITY } from={ facts.from } to={ facts.to } t={ t } />
205
+ </Anchored>
188
206
  ) }
189
207
 
190
208
  <Anchored>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.37.8",
3
+ "version": "1.37.9",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/styles.ts CHANGED
@@ -153,4 +153,41 @@ export const Listing = styled.div`
153
153
  // column wider than its share of the row
154
154
  flex: 1;
155
155
  min-width: 0;
156
+
157
+ /*
158
+ * Edited: Claude - Date: 2026-09-16
159
+ *
160
+ * Real room at the FOOT of the list, on a narrow screen, for whatever the
161
+ * cookie notice (packages/common/CookieNotification) is currently taking
162
+ * up down there - not the fixed "20px" the notice used to assume, since a
163
+ * fixed guess is wrong the moment the sentence wraps to a different number
164
+ * of lines in a longer language. --obt-cookie-notice-height is the
165
+ * notice's own measured height, published as a CSS custom property so
166
+ * this column never has to ask it directly; 0px when the notice is not
167
+ * showing, so a visitor who has already dismissed it loses nothing here.
168
+ */
169
+ @media (max-width: 639px) {
170
+ padding-bottom: var(--obt-cookie-notice-height, 0px);
171
+ }
172
+ `;
173
+
174
+ /**
175
+ * A placeholder block, sized as a fraction of its cell.
176
+ *
177
+ * Edited: Claude - Date: 2026-09-16
178
+ *
179
+ * Shared by the two loading skeletons on this page - Availability/Skeleton
180
+ * and Found/Orders/Skeleton - so the one thing they agree on (what a
181
+ * "nothing has arrived yet" rectangle looks like) is defined once rather
182
+ * than twice. A flat fill, not the shimmering-gradient kind: the whole job
183
+ * of a skeleton here is to hold the page still while data arrives, and an
184
+ * animated one is itself something moving on the page for the second or so
185
+ * it is visible.
186
+ */
187
+ export const Bone = styled.span<{ $width: number, $height?: number }>`
188
+ display: inline-block;
189
+ width: ${ props => props.$width }%;
190
+ height: ${ props => props.$height ?? 14 }px;
191
+ border-radius: 4px;
192
+ background: ${ props => props.theme.background.neutral };
156
193
  `;