From 6deac420332c67d066824b2a84f31898e4411bba Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sun, 25 Oct 2026 20:28:56 +0300 Subject: [PATCH] feat: enhance departure status display and loading state handling --- .gitignore | 1 + src/App.tsx | 17 ++++++++++++----- src/hooks.ts | 4 +++- src/styles.css | 10 ++++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index d29ba0b..c52c9d9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ test-results/ playwright-report/ .DS_Store +.impeccable/ diff --git a/src/App.tsx b/src/App.tsx index 907f46a..7c89bde 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,7 +52,11 @@ function ScheduledTime({ departure, now, className = '', showDay = true }: { dep function DepartureStatus({ departure, now, large = false }: { departure: DisplayDeparture; now: number; large?: boolean }) { return
{departure.status === 'cancelled' ? Отменён : } - {departure.status === 'notice' && Есть изменения} + {/* The large countdown must not read as a live arrival estimate. One line + with the change flag keeps the hero's height unchanged. */} + {large && departure.status !== 'cancelled' ? + {departure.status === 'notice' && Есть изменения} + : departure.status === 'notice' && Есть изменения}
; } @@ -64,6 +68,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined; const waiting = longWait(settings, first, now); const reading = readability(settings); + const lastTrip = Boolean(first && first.id === lastTripId); const table = useRef(null); const [tableHeight, setTableHeight] = useState(360); const [page, setPage] = useState(0); @@ -83,7 +88,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo return () => clearInterval(timer); }, [paused, visible.pages, reading.calm]); return
-
+
БЛИЖАЙШИЙ РЕЙС По расписанию{data?.mode === 'demo' ? ' · демо' : ''}
{first ?
@@ -94,7 +99,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo {waiting ?
{localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'}{first.status === 'notice' && Есть изменения}
: }
:
0}>

{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}

{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}

} - {first && first.id === lastTripId && } + {first && lastTrip && }
{!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'}{visible.pages > 1 ? {visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count} : }
1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}> {visible.items.map((departure, index) =>
@@ -309,7 +314,9 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
{direction &&
{direction}
}
{!night.active && }
{dateFormat.format(now)}
- {!fresh && !settings.demo &&
{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}
} + {!fresh && !settings.demo && (loading + ?
{data ? 'Обновляем расписание…' : 'Подключаемся к RideBus…'}
+ :
{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : 'Не удалось загрузить расписание. Проверьте подключение.'}
)} {management?.failed &&
Нет связи с управлением · сохранённые настройки
}
{night.active && night.first && } @@ -321,7 +328,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState; {!night.active && scene !== 'departures' &&
БЛИЖАЙШИЙ РЕЙС{first ? <>
{first.destination}{guide.lastTripId === first.id && }
: Нет доступных рейсов}
}
-
{!fresh && !settings.demo && }{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}
+
{!fresh && !settings.demo && !loading && }{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}
{notice &&
{notice}
} diff --git a/src/hooks.ts b/src/hooks.ts index de0097e..3ba55c8 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -62,8 +62,10 @@ export function useBoard(settings: Settings, config: Config) { const demoTimer = setInterval(() => setData(createDemo()), 60000); return () => clearInterval(demoTimer); } + // A saved snapshot fills the screen while the first request is still in + // flight; that is not a connection failure until the request fails. readSnapshot(config.cityId, settings.stopId).then(cached => { - if (!stopped && !received && cached) { setData(cached); setFailed(true); setLoading(false); setOfflineSaved(true); } + if (!stopped && !received && cached) { setData(cached); setOfflineSaved(true); } }).catch(() => { /* Offline storage may be unavailable in private mode. */ }); async function load() { if (running || stopped) return; diff --git a/src/styles.css b/src/styles.css index 441ad0d..7368fea 100644 --- a/src/styles.css +++ b/src/styles.css @@ -33,6 +33,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .scene { width: 100%; height: 100%; min-height: 0; animation: scene-in 400ms ease-out both; } .departure-layout { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: clamp(12px, 1.8vh, 24px) 0; } .departure-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: auto calc(var(--hero-height) + var(--hero-gap)) auto minmax(0, 1fr); } +/* The last-trip notice is an extra child; give it its own row so the table keeps the flexible one. */ +.departure-content[data-last-trip='true'] { grid-template-rows: auto calc(var(--hero-height) + var(--hero-gap)) auto auto minmax(0, 1fr); } .section-heading, .next-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; min-width: 0; } .eyebrow { font-size: clamp(12px, 1.05vw, 21px); font-weight: 650; letter-spacing: .12em; color: var(--muted); } .source-label { display: flex; align-items: center; gap: 8px; color: var(--secondary); font-size: clamp(13px, 1.15vw, 23px); white-space: nowrap; } @@ -78,6 +80,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .quiet-placeholder { display: none; } .offline-banner { display: flex; align-items: center; gap: 9px; color: var(--warning); font-size: clamp(13px, 1.2vw, 22px); padding-top: 9px; flex: 0 0 auto; } .offline-banner button { color: inherit; border: 0; background: none; display: flex; padding: 3px; margin-left: auto; } +.offline-banner[data-state='loading'] { color: var(--secondary); } /* Cards switch to one panel when the actual available area is too small. */ .info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 16px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--outline) var(--background); } @@ -564,6 +567,9 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .hero-next time { color: var(--accent); display: inline-flex; align-items: baseline; gap: 6px; font-weight: 650; } .departure-status { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 6px; } .departure-change { color: var(--warning); font-size: clamp(12px, calc(1.1vw * var(--reading-scale)), 20px); line-height: 1.2; } +.countdown-meta { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: baseline; gap: 2px .7em; text-align: right; font-size: clamp(14px, calc(var(--hero-height) * .12), calc(24px * var(--reading-scale))); line-height: 1.2; } +.countdown-meta .departure-change { font-size: 1em; } +.countdown-source { color: var(--secondary); font-weight: 500; white-space: nowrap; } .cancelled-label { display: inline-flex; align-items: center; gap: 8px; color: var(--warning); font-size: min(calc(26px * var(--reading-scale)), calc(var(--row-height, 96px) * .31), 5.5vw); font-weight: 650; white-space: nowrap; } .cancelled-label svg { width: .9em; height: .9em; } .departure-row[data-status='cancelled'] .route-badge { color: var(--warning); border-color: var(--warning); } @@ -597,6 +603,9 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .hero-next { font-size: 15px; gap: 4px 7px; margin-top: 6px; } .departure-status { gap: 4px; } .departure-change { font-size: 12px; } + /* Wrap within the countdown's width rather than squeezing the destination. */ + .countdown-meta { font-size: 13px; width: min-content; min-width: 100%; } + .countdown-source { white-space: normal; } .hero-departure[data-long-wait='true'] { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; gap: 8px 12px; } .hero-departure[data-long-wait='true'] > .route-badge { grid-row: 1 / 3; width: min(calc(var(--hero-height) * .7), 110px); height: min(calc(var(--hero-height) - 10px), 160px); } .hero-departure[data-long-wait='true'] .transport-kind { display: none; } @@ -608,6 +617,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .board[data-distance='far'] .hero-departure:not([data-long-wait='true']) > .route-badge { grid-row: 1 / 3; width: 110px; height: min(calc(var(--hero-height) - 10px), 160px); } .board[data-distance='far'] .hero-departure:not([data-long-wait='true']) .transport-kind { display: none; } .board[data-distance='far'] .hero-departure > .departure-status { grid-column: 2; align-items: flex-start; } + .board[data-distance='far'] .hero-departure .countdown-meta { justify-content: flex-start; text-align: left; } .board[data-distance='far'] .hero-departure .countdown-large .countdown-value { font-size: 64px; } } ::view-transition { pointer-events: none; }