@autobusal/routes-order 1.33.0 → 1.33.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.33.1
4
+
5
+ ### Added
6
+
7
+ - **A pair with no future scheduled service says so**, with the date it last ran, instead of showing a facts block that quietly omits frequency and a departure list that is empty on every date the visitor tries. The links to journeys that do run stay prominent, so the page converts rather than dead-ends. No `noindex`: an honest page about a seasonal route is not thin content, and a route that stopped in December will want its ranking back in June.
8
+
3
9
  ## 1.33.0
4
10
 
5
11
  ### Added
package/Facts/Facts.tsx CHANGED
@@ -1,7 +1,8 @@
1
1
  import { TFunction } from 'i18next';
2
- import { Container, Grid, Fact, Label, Value, Note, Operators, Prose } from './styles';
2
+ import { Container, Grid, Fact, Label, Value, Note, Notice, Operators, Prose } from './styles';
3
3
  import { FactsData } from './types';
4
4
  import { DAYS, duration } from './questions';
5
+ import { stopped, detail } from './service';
5
6
 
6
7
  interface Props {
7
8
  id: string
@@ -81,10 +82,46 @@ const Facts = ({ id, facts, t }: Props): JSX.Element => {
81
82
  : `${ duration(facts.duration_min, t) } – ${ duration(facts.duration_max, t) }`)
82
83
  : null;
83
84
 
85
+ /**
86
+ * Edited: Claude - Date: 2026-08-21
87
+ *
88
+ * SAY IT, rather than leaving a reader to work it out. A route is
89
+ * published to the sitemap on a flag nothing re-checks, so a season
90
+ * ending never un-publishes its page - and the page's only tell was a
91
+ * missing frequency tile and a booking wizard that came back empty on
92
+ * every date tried. That is a visitor doing our diagnostics for us and
93
+ * concluding the site is broken.
94
+ *
95
+ * DORMANT NAMES THE DATE. "Not currently scheduled" alone reads as a
96
+ * fault; "the last service ran on 30 September 2024" reads as a season,
97
+ * which is what it usually is, and tells a traveller whether to come back
98
+ * in the summer or give up. A route that never had a timetable has no
99
+ * such date and gets the sentence that says so instead of a borrowed one.
100
+ *
101
+ * AND IT POINTS ONWARD in the same breath. The nearby-pair links below
102
+ * are live journeys on live routes; somebody who came here for a bus is
103
+ * one click from one, and the whole reason this page keeps its ranking
104
+ * rather than being de-listed is that it can make that click happen.
105
+ */
106
+ const dead = stopped(facts);
107
+
108
+ const said = dead ? detail(dead, t) : null;
109
+
84
110
  return (
85
111
  <Container id={ id } className="box">
86
112
  <h2>{ t('routes_order.facts.title', { from: facts.from, to: facts.to }) }</h2>
87
113
 
114
+ { dead && (
115
+ <Notice>
116
+ <strong>{ t('routes_order.facts.not_scheduled') }</strong>
117
+
118
+ <p>
119
+ { said && `${ said } ` }
120
+ { t('routes_order.facts.not_scheduled_links') }
121
+ </p>
122
+ </Notice>
123
+ ) }
124
+
88
125
  <Grid>
89
126
  { facts.price_from_display && (
90
127
  <Fact>
@@ -1,5 +1,6 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import { FactsData } from './types';
3
+ import { stopped, sentence } from './service';
3
4
 
4
5
  export interface Question {
5
6
  // stable enough to key a list on, and never rendered
@@ -66,6 +67,40 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
66
67
 
67
68
  const cities = { from: facts.from, to: facts.to };
68
69
 
70
+ /**
71
+ * Edited: Claude - Date: 2026-08-21
72
+ *
73
+ * THE FIRST QUESTION, WHEN THE ANSWER IS NO. "Is there a bus from Tirana
74
+ * to Prizren?" is the question a pair page exists to answer, and on a
75
+ * pair whose every timetable window has closed the honest answer is not
76
+ * currently, with the day it last ran.
77
+ *
78
+ * It also dictates which of the questions BELOW may be asked, which is
79
+ * the part that matters more than the extra entry. Every answer here is
80
+ * a standalone sentence that Google may lift into a rich result with no
81
+ * page around it, so "There are 4 scheduled services from Tirana to
82
+ * Prizren" quoted on its own about a route that stopped in 2025 is a
83
+ * claim we published, not a nuance a reader will supply. The four
84
+ * present-tense SERVICE COUNTS - how many run, what time they leave,
85
+ * how many are direct, how many are overnight - are dropped there.
86
+ * (Frequency drops itself: obtapi already sends null for it.)
87
+ *
88
+ * What stays is what remains true of the journey as published: how long
89
+ * it takes, what it cost, where it calls, what is on board, who runs it,
90
+ * how far it is. Those describe the route rather than assert this
91
+ * morning's departures, and each of them sits under an answer that has
92
+ * already said the route is not running.
93
+ */
94
+ const dead = stopped(facts);
95
+
96
+ if (dead) {
97
+ all.push({
98
+ id: 'service',
99
+ q: t('routes_order.schema.service_q', cities),
100
+ a: sentence(dead, t)
101
+ });
102
+ }
103
+
69
104
  if (facts.duration_min !== null && facts.duration_max !== null) {
70
105
  all.push({
71
106
  id: 'duration',
@@ -104,7 +139,7 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
104
139
  });
105
140
  }
106
141
 
107
- if (facts.departures > 0) {
142
+ if (facts.departures > 0 && !dead) {
108
143
  all.push({
109
144
  id: 'departures',
110
145
  q: t('routes_order.schema.departures_q', cities),
@@ -127,7 +162,7 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
127
162
  });
128
163
  }
129
164
 
130
- if (facts.departure_first && facts.departure_last) {
165
+ if (facts.departure_first && facts.departure_last && !dead) {
131
166
  all.push({
132
167
  id: 'times',
133
168
  q: t('routes_order.schema.times_q', cities),
@@ -137,7 +172,7 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
137
172
 
138
173
  // Asked only where the answer is a real count. `direct` is meaningless
139
174
  // without a total to read it against, and `departures` is that total.
140
- if (facts.departures > 0) {
175
+ if (facts.departures > 0 && !dead) {
141
176
  all.push({
142
177
  id: 'direct',
143
178
  q: t('routes_order.schema.direct_q', cities),
@@ -151,7 +186,7 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
151
186
 
152
187
  // Only when there IS a night service. "No, none of these run overnight" is
153
188
  // a question asked to be answered no, which is padding.
154
- if (facts.overnight > 0) {
189
+ if (facts.overnight > 0 && !dead) {
155
190
  all.push({
156
191
  id: 'overnight',
157
192
  q: t('routes_order.schema.overnight_q', cities),
@@ -0,0 +1,111 @@
1
+ import { TFunction } from 'i18next';
2
+ import { FactsData } from './types';
3
+
4
+ /**
5
+ * A pair that is published but has nothing left to sell.
6
+ *
7
+ * Edited: Claude - Date: 2026-08-21
8
+ *
9
+ * ONE READING OF THE FLAG, shared by everything that reacts to it - the
10
+ * notice in the facts block, the FAQ answer that a search engine quotes,
11
+ * and the schema.org availability. Three separate `facts.service?.scheduled
12
+ * === false` checks would be three chances for the page to say one thing
13
+ * and its own structured data to say another, which is the exact mismatch
14
+ * that gets a site's rich results pulled.
15
+ *
16
+ * `dormant` is a pair that RAN and stopped, and `last` is the day it last
17
+ * ran. `never` is a route published before anybody gave it a timetable -
18
+ * there is no last day to name, and the copy must not imply there was one.
19
+ *
20
+ * NULL for a live pair AND for an old cached payload with no `service` key
21
+ * at all (obtapi caches these for six hours, so the field is missing from
22
+ * warm entries for six hours after a deploy). Both mean "say nothing",
23
+ * which is right: an absent field is not evidence that a route is dead.
24
+ */
25
+ export interface Stopped {
26
+ state: 'dormant' | 'never'
27
+ last: string | null
28
+ }
29
+
30
+ export const stopped = (facts: FactsData): (Stopped | null) => {
31
+ if (!facts.service || facts.service.scheduled !== false) {
32
+ return null;
33
+ }
34
+
35
+ return facts.service.last
36
+ ? { state: 'dormant', last: facts.service.last }
37
+ : { state: 'never', last: null };
38
+ };
39
+
40
+ /**
41
+ * ISO month index to the month names the site already ships in all fifteen
42
+ * languages (`data.months.*`), rather than a new set nobody would have
43
+ * translated for one sentence.
44
+ */
45
+ const MONTHS = [
46
+ 'january', 'february', 'march', 'april', 'may', 'june',
47
+ 'july', 'august', 'september', 'october', 'november', 'december'
48
+ ];
49
+
50
+ /**
51
+ * "The last service ran on 30 September 2024", in the reader's language.
52
+ *
53
+ * Edited: Claude - Date: 2026-08-21
54
+ *
55
+ * The day, month and year go in as THREE interpolations rather than one
56
+ * pre-joined string, so a locale that orders them differently can. obtapi
57
+ * sends the date as ISO precisely because it is the one format that does
58
+ * not already contain somebody's opinion about that ordering.
59
+ *
60
+ * Returns null on anything that is not an ISO date rather than printing a
61
+ * half-parsed one. A malformed date here would be printed as fact in a
62
+ * sentence about when a bus last ran, and no date at all is better than a
63
+ * wrong one - the headline above it already carries the actual message.
64
+ */
65
+ export const ran = (iso: string, t: TFunction<'common'>): (string | null) => {
66
+ const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
67
+
68
+ if (!parts) {
69
+ return null;
70
+ }
71
+
72
+ const month = MONTHS[Number(parts[2]) - 1];
73
+
74
+ if (!month) {
75
+ return null;
76
+ }
77
+
78
+ return t('routes_order.facts.not_scheduled_last', {
79
+ day: String(Number(parts[3])),
80
+ month: t(`data.months.${ month }`),
81
+ year: parts[1]
82
+ });
83
+ };
84
+
85
+ /**
86
+ * The second sentence: when it last ran, or that it never has.
87
+ *
88
+ * Null rather than the never-scheduled wording when a DORMANT pair's date
89
+ * fails to parse - "a timetable has not been published yet" about a coach
90
+ * that ran for two years is a different false statement, not a graceful
91
+ * fallback. The headline carries the message on its own there.
92
+ */
93
+ export const detail = (item: Stopped, t: TFunction<'common'>): (string | null) => (
94
+ item.last ? ran(item.last, t) : (item.state === 'never' ? t('routes_order.facts.not_scheduled_never') : null)
95
+ );
96
+
97
+ /**
98
+ * The whole message as one paragraph of running text.
99
+ *
100
+ * Shared with the FAQ answer for the same reason the flag is: Google
101
+ * requires the `acceptedAnswer` in the markup to be the text on the page,
102
+ * and the only way to guarantee that across fifteen languages is for there
103
+ * to be one string rather than two that agree today.
104
+ */
105
+ export const sentence = (item: Stopped, t: TFunction<'common'>): string => {
106
+ const second = detail(item, t);
107
+
108
+ return second
109
+ ? `${ t('routes_order.facts.not_scheduled') } ${ second }`
110
+ : t('routes_order.facts.not_scheduled');
111
+ };
package/Facts/styles.ts CHANGED
@@ -72,3 +72,41 @@ export const Prose = styled.div`
72
72
  line-height: 1.6;
73
73
  }
74
74
  `;
75
+
76
+ /**
77
+ * The pair is published but nothing on it runs.
78
+ *
79
+ * Edited: Claude - Date: 2026-08-21
80
+ *
81
+ * ABOVE the grid and inside the same box, because it changes how every
82
+ * number below it is to be read: those fares and times are what the
83
+ * operator last published, not what is on sale this morning. A banner
84
+ * floating outside the block would leave the grid looking like a live
85
+ * offer with a disclaimer somewhere else on the page.
86
+ *
87
+ * Informational, not an error - `font.info` rather than `font.error`. A
88
+ * seasonal coach is a normal thing that has happened, and dressing it as a
89
+ * fault would push a reader to leave rather than to look at the routes that
90
+ * ARE running a few hundred pixels down.
91
+ */
92
+ export const Notice = styled.div`
93
+ margin: 0 0 22px;
94
+ padding: 14px 16px;
95
+ border-left: 3px solid ${ props => props.theme.font.info };
96
+ border-radius: ${ props => props.theme.borderRadius };
97
+ background: ${ props => props.theme.background.neutral };
98
+ line-height: 1.6;
99
+
100
+ p {
101
+ margin: 0;
102
+ }
103
+
104
+ /* The headline claim, in the reader's weight rather than a heading level:
105
+ the block already owns an <h2> and a second one here would put "not
106
+ currently scheduled" into the document outline above the facts it
107
+ qualifies. */
108
+ strong {
109
+ display: block;
110
+ font-weight: 700;
111
+ }
112
+ `;
package/Facts/types.ts CHANGED
@@ -59,6 +59,36 @@ export interface FactsData {
59
59
  * and obtapi withholds the whole object rather than counting it.
60
60
  */
61
61
  frequency: { days: number[], weekly: number, daily: boolean } | null
62
+ /**
63
+ * Edited: Claude - Date: 2026-08-21
64
+ *
65
+ * WHETHER ANY OF THIS IS STILL ON OFFER. A route is published to the
66
+ * sitemap on a `searchable` flag that nothing re-checks, so a season
67
+ * ending never un-publishes the page - and the page had no way to know.
68
+ * It rendered a facts block quietly missing its frequency and a departure
69
+ * list that came back empty for every date the visitor tried.
70
+ *
71
+ * `scheduled` false means NO leg on this pair has a timetable window that
72
+ * is still open, which is the same test the search applies - so nothing
73
+ * can be bought here on any date. `last` is then the day the last of them
74
+ * ran, or NULL when no leg ever had a window at all (a route published
75
+ * before anybody gave it a timetable). Those two want different
76
+ * sentences: one has a date to name and the other does not, and inventing
77
+ * one would be a lie.
78
+ *
79
+ * `last` is deliberately null while `scheduled` is true - there it would
80
+ * be some other leg's season ending, printed where a reader reads "the
81
+ * last day this ran".
82
+ *
83
+ * OPTIONAL, and that is not laziness: obtapi caches this payload for six
84
+ * hours, so for six hours after a deploy the field is simply absent from
85
+ * warm entries. Absent means WE DID NOT COMPUTE IT and the page says
86
+ * nothing - which is also the honest reading of `frequency: null` with
87
+ * `scheduled: true`, a pair whose windows are open but whose operators
88
+ * never listed their days. "We cannot say how often" and "it does not
89
+ * run" are different claims and only the second one gets the notice.
90
+ */
91
+ service?: { scheduled: boolean, last: string | null }
62
92
  // how many of the `departures` run non-stop, and how many are still
63
93
  // rolling the next morning - counted out of the total rather than reported
64
94
  // as a yes/no, because "one of ten is direct" is the honest version
@@ -2,6 +2,7 @@ import { TFunction } from 'i18next';
2
2
  import { JsonLd } from '@autobusal/common';
3
3
  import { FactsData } from '../Facts/types';
4
4
  import { Question } from '../Facts/questions';
5
+ import { stopped } from '../Facts/service';
5
6
 
6
7
  interface Props {
7
8
  facts: FactsData
@@ -144,7 +145,19 @@ const Schema = ({ facts, questions, t }: Props): (JSX.Element | null) => {
144
145
  '@type': 'Offer',
145
146
  price: facts.price_from,
146
147
  priceCurrency: facts.currency,
147
- availability: 'https://schema.org/InStock'
148
+
149
+ // Edited: Claude - Date: 2026-08-21
150
+ // The availability is now READ rather than asserted. This said
151
+ // InStock on every pair it was ever emitted for, including the
152
+ // ones whose every timetable window had closed - a machine-readable
153
+ // claim that a ticket could be bought, published about journeys
154
+ // that sell on no date a visitor can pick. OutOfStock is the same
155
+ // thing the page itself now says above the fare, which is the
156
+ // whole rule this file opens with: never state here what the page
157
+ // does not show.
158
+ availability: stopped(facts)
159
+ ? 'https://schema.org/OutOfStock'
160
+ : 'https://schema.org/InStock'
148
161
  }
149
162
  } : {})
150
163
  };
@@ -6,6 +6,7 @@ import Links from '../Links/Links';
6
6
  import Schema from './Schema';
7
7
  import { useGetFacts } from '../Facts/services';
8
8
  import buildQuestions from '../Facts/questions';
9
+ import { stopped } from '../Facts/service';
9
10
  import { Nav, Anchor, Anchored } from './styles';
10
11
 
11
12
  interface Props {
@@ -65,6 +66,42 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
65
66
  // that happen to agree today.
66
67
  const questions = buildQuestions(facts, t);
67
68
 
69
+ /**
70
+ * Edited: Claude - Date: 2026-08-21
71
+ *
72
+ * ON A PAIR THAT HAS STOPPED RUNNING, THE WAY OUT COMES FIRST. The facts
73
+ * block now says plainly that the route is not currently scheduled - and
74
+ * the moment it does, the most useful thing on the page is the list of
75
+ * nearby pairs that ARE running, not four hundred pixels of timetable
76
+ * the reader has just been told they cannot travel on.
77
+ *
78
+ * Hoisted rather than duplicated: it is the same <Links> element, moved,
79
+ * so a crawler still finds exactly one copy of each internal link. That
80
+ * is the whole reason this page keeps its ranking instead of being
81
+ * de-listed - an honest seasonal page that converts to a live journey is
82
+ * worth more than a 404, and it only converts if the link is where the
83
+ * reader is looking.
84
+ *
85
+ * Everything below stays exactly where it was. The timetable is still
86
+ * what the operator published and the FAQ still answers the questions
87
+ * this journey raises; neither is hidden, because a page that hides its
88
+ * content to admit a problem is thin content, which is what noindex is
89
+ * for and what this deliberately is not.
90
+ */
91
+ const onward = (
92
+ <Links
93
+ id={ LINKS }
94
+ from={ links.from }
95
+ to={ links.to }
96
+ reverse={ links.reverse }
97
+ fromName={ facts.from }
98
+ toName={ facts.to }
99
+ t={ t }
100
+ />
101
+ );
102
+
103
+ const dead = Boolean(stopped(facts));
104
+
68
105
  return (
69
106
  <>
70
107
  <Nav aria-label={ t('routes_order.sections.label') }>
@@ -89,6 +126,8 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
89
126
  <Facts id={ FACTS } facts={ facts } t={ t } />
90
127
  </Anchored>
91
128
 
129
+ { dead && onward }
130
+
92
131
  <Anchored>
93
132
  <Schedule id={ SCHEDULE } from={ facts.from } to={ facts.to } rows={ schedule } t={ t } />
94
133
  </Anchored>
@@ -113,15 +152,7 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
113
152
  The `pair` mode of Reviews\BrowseController is untouched and
114
153
  still works; nothing calls it from here. */ }
115
154
 
116
- <Links
117
- id={ LINKS }
118
- from={ links.from }
119
- to={ links.to }
120
- reverse={ links.reverse }
121
- fromName={ facts.from }
122
- toName={ facts.to }
123
- t={ t }
124
- />
155
+ { !dead && onward }
125
156
  </>
126
157
  );
127
158
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.33.0",
3
+ "version": "1.33.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"