fix(dashboard): show an error instead of a blank trip list when the server is unreachable (#1283)

When the backend or identity provider was unreachable, a returning user with a
persisted session landed on the dashboard with an empty trip grid and no error.
That looks identical to a logged-in user who simply has no trips, so people
assumed their data had been lost.

Three client-side layers were quietly swallowing the failure: the auth check
only cleared state on a 401, so a 5xx or a network error left the stale session
in place and kept rendering the protected route; the offline-first trip repo
turned a failed fetch into the empty cache without throwing; and the dashboard
had neither an error nor an empty state, so a blank grid meant both "outage" and
"no trips".

The auth check now tells genuine offline (keep serving the cache silently, the
PWA happy path) apart from a server outage while online (keep the session but
flag it). The dashboard shows a reassuring "couldn't reach the server, your
trips are safe" banner with a retry, and a real zero-trip account finally gets a
proper empty state so the two cases never look alike. New strings added across
all locales.
This commit is contained in:
Maurice
2026-06-21 23:08:25 +02:00
parent a074debd61
commit c0b5d941dd
24 changed files with 123 additions and 6 deletions
+17
View File
@@ -84,6 +84,7 @@ export default function DashboardPage(): React.ReactElement {
const {
demoMode, locale, t, navigate,
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError, retryLoad,
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
@@ -102,6 +103,15 @@ export default function DashboardPage(): React.ReactElement {
<MobileTopBar />
<main className="page">
<div className="page-main">
{loadError && (
<div className="dash-error" role="alert">
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
<button className="dash-error-retry" onClick={retryLoad}>
<RefreshCw size={15} />
{t('dashboard.retry')}
</button>
</div>
)}
{spotlight && (
<BoardingPassHero
trip={spotlight}
@@ -132,6 +142,13 @@ export default function DashboardPage(): React.ReactElement {
</div>
</div>
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
<h4>{t('dashboard.emptyTitle')}</h4>
<p>{t('dashboard.emptyText')}</p>
</div>
)}
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
{gridTrips.map(trip => (
<TripCard
+12 -1
View File
@@ -33,6 +33,7 @@ export function useDashboard() {
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
const [loadError, setLoadError] = useState<boolean>(false)
const [stats, setStats] = useState<TravelStats | null>(null)
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
@@ -42,7 +43,7 @@ export function useDashboard() {
const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
const toggleViewMode = () => {
setViewMode(prev => {
@@ -74,13 +75,22 @@ export function useDashboard() {
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setLoadError(false)
} catch {
setLoadError(true)
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
// Re-run both the trip fetch and the auth check so a recovered backend clears
// the error banner (loadUser resets authCheckFailed on success). #1283
const retryLoad = () => {
loadUser({ silent: true })
loadTrips()
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
@@ -177,6 +187,7 @@ export function useDashboard() {
demoMode, locale, t, navigate,
// data + derived
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError: loadError || authCheckFailed, retryLoad,
// ui state
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
+23 -5
View File
@@ -25,6 +25,11 @@ interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
/** The auth check (loadUser) failed for a non-401 reason while we were online —
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
* outage doesn't render as a blank, error-free page that looks like lost data.
* Transient, never persisted. #1283 */
authCheckFailed: boolean
error: string | null
demoMode: boolean
devMode: boolean
@@ -86,6 +91,7 @@ export const useAuthStore = create<AuthState>()(
user: null,
isAuthenticated: false,
isLoading: true,
authCheckFailed: false,
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
@@ -200,6 +206,7 @@ export const useAuthStore = create<AuthState>()(
set({
user: null,
isAuthenticated: false,
authCheckFailed: false,
error: null,
})
},
@@ -215,22 +222,33 @@ export const useAuthStore = create<AuthState>()(
user: data.user,
isAuthenticated: true,
isLoading: false,
authCheckFailed: false,
})
await onAuthSuccess(data.user.id)
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
// Only clear auth state on 401 (invalid/expired token), not on network errors
const isAuthError = err && typeof err === 'object' && 'response' in err &&
(err as { response?: { status?: number } }).response?.status === 401
if (isAuthError) {
const status = err && typeof err === 'object' && 'response' in err
? (err as { response?: { status?: number } }).response?.status
: undefined
if (status === 401) {
// Invalid/expired token — clear auth so the guard redirects to login.
set({
user: null,
isAuthenticated: false,
isLoading: false,
authCheckFailed: false,
})
} else {
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
// Genuinely offline — keep the persisted session so the PWA serves cached
// data without a scary error. This is the offline-first happy path.
set({ isLoading: false })
} else {
// Server erroring (5xx) or unreachable while we're online: keep the session
// (don't eject the user over a transient outage), but flag it so the UI can
// say "couldn't reach the server" instead of showing a blank, error-free
// page that looks like the user's trips were lost. #1283
set({ isLoading: false, authCheckFailed: true })
}
}
},
+27
View File
@@ -456,6 +456,33 @@
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
/* Error banner — shown when the trip list or the auth check couldn't reach the
server, so a backend/IdP outage no longer looks like an empty (lost-data)
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
.trek-dash .dash-error {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 14px 18px; margin-bottom: 22px;
background: oklch(0.74 0.14 75 / 0.13);
border: 1px solid oklch(0.74 0.14 75 / 0.45);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
}
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
.trek-dash .dash-error-retry {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 14px; border: none; border-radius: var(--r-xs);
background: var(--ink); color: var(--surface);
font-size: 13px; font-weight: 500; cursor: pointer;
transition: opacity .15s ease;
}
.trek-dash .dash-error-retry:hover { opacity: .88; }
/* Empty state — a genuine "you have no trips yet" message, visually distinct
from the error banner above so an outage and a real empty list never look alike. */
.trek-dash .trips-empty { margin-bottom: 18px; }
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
/* ----------------- tools sidebar ----------------- */
.trek-dash .tool {
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'منتهية',
'dashboard.status.daysLeft': 'متبقي {count} يوم',
'dashboard.toast.loadError': 'فشل تحميل الرحلات',
'dashboard.loadErrorBanner': 'تعذّر الوصول إلى الخادم. رحلاتك في أمان — يرجى المحاولة مرة أخرى.',
'dashboard.retry': 'إعادة المحاولة',
'dashboard.toast.created': 'تم إنشاء الرحلة بنجاح',
'dashboard.toast.createError': 'فشل إنشاء الرحلة',
'dashboard.toast.updated': 'تم تحديث الرحلة',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Passada',
'dashboard.status.daysLeft': 'Faltam {count} dias',
'dashboard.toast.loadError': 'Não foi possível carregar as viagens',
'dashboard.loadErrorBanner': 'Não foi possível conectar ao servidor. Suas viagens estão seguras — tente novamente.',
'dashboard.retry': 'Tentar novamente',
'dashboard.toast.created': 'Viagem criada com sucesso!',
'dashboard.toast.createError': 'Não foi possível criar a viagem',
'dashboard.toast.updated': 'Viagem atualizada!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Proběhlé',
'dashboard.status.daysLeft': 'zbývá {count} dní',
'dashboard.toast.loadError': 'Nepodařilo se načíst cesty',
'dashboard.loadErrorBanner': 'Server nebyl dostupný. Vaše cesty jsou v bezpečí — zkuste to prosím znovu.',
'dashboard.retry': 'Zkusit znovu',
'dashboard.toast.created': 'Cesta byla úspěšně vytvořena!',
'dashboard.toast.createError': 'Nepodařilo se vytvořit cestu',
'dashboard.toast.updated': 'Cesta byla aktualizována!',
+2
View File
@@ -43,6 +43,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Vergangen',
'dashboard.status.daysLeft': 'Noch {count} Tage',
'dashboard.toast.loadError': 'Fehler beim Laden der Reisen',
'dashboard.loadErrorBanner': 'Server nicht erreichbar. Deine Reisen sind sicher — bitte versuche es erneut.',
'dashboard.retry': 'Erneut versuchen',
'dashboard.toast.created': 'Reise erfolgreich erstellt!',
'dashboard.toast.createError': 'Fehler beim Erstellen',
'dashboard.toast.updated': 'Reise aktualisiert!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Past',
'dashboard.status.daysLeft': '{count} days left',
'dashboard.toast.loadError': 'Failed to load trips',
'dashboard.loadErrorBanner': "Couldn't reach the server. Your trips are safe — please try again.",
'dashboard.retry': 'Retry',
'dashboard.toast.created': 'Trip created successfully!',
'dashboard.toast.createError': 'Failed to create trip',
'dashboard.toast.updated': 'Trip updated!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Pasado',
'dashboard.status.daysLeft': 'Quedan {count} días',
'dashboard.toast.loadError': 'No se pudieron cargar los viajes',
'dashboard.loadErrorBanner': 'No se pudo conectar con el servidor. Tus viajes están a salvo: inténtalo de nuevo.',
'dashboard.retry': 'Reintentar',
'dashboard.toast.created': '¡Viaje creado correctamente!',
'dashboard.toast.createError': 'No se pudo crear el viaje',
'dashboard.toast.updated': '¡Viaje actualizado!',
+3
View File
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Passé',
'dashboard.status.daysLeft': '{count} jours restants',
'dashboard.toast.loadError': 'Impossible de charger les voyages',
'dashboard.loadErrorBanner':
"Impossible de joindre le serveur. Vos voyages sont en sécurité — veuillez réessayer.",
'dashboard.retry': 'Réessayer',
'dashboard.toast.created': 'Voyage créé avec succès !',
'dashboard.toast.createError': 'Impossible de créer le voyage',
'dashboard.toast.updated': 'Voyage mis à jour !',
+3
View File
@@ -41,6 +41,9 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Παρελθόν',
'dashboard.status.daysLeft': '{count} μέρες έμειναν',
'dashboard.toast.loadError': 'Αποτυχία φόρτωσης ταξιδιών',
'dashboard.loadErrorBanner':
'Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Τα ταξίδια σας είναι ασφαλή — δοκιμάστε ξανά.',
'dashboard.retry': 'Δοκιμάστε ξανά',
'dashboard.toast.created': 'Ταξίδι δημιουργήθηκε επιτυχώς!',
'dashboard.toast.createError': 'Αποτυχία δημιουργίας ταξιδιού',
'dashboard.toast.updated': 'Ταξίδι ενημερώθηκε!',
+2
View File
@@ -43,6 +43,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Múlt',
'dashboard.status.daysLeft': 'Még {count} nap',
'dashboard.toast.loadError': 'Nem sikerült betölteni az utazásokat',
'dashboard.loadErrorBanner': 'Nem sikerült elérni a kiszolgálót. Az utazásaid biztonságban vannak — kérlek, próbáld újra.',
'dashboard.retry': 'Újra',
'dashboard.toast.created': 'Utazás sikeresen létrehozva!',
'dashboard.toast.createError': 'Nem sikerült létrehozni',
'dashboard.toast.updated': 'Utazás frissítve!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Sudah lewat',
'dashboard.status.daysLeft': '{count} hari lagi',
'dashboard.toast.loadError': 'Gagal memuat perjalanan',
'dashboard.loadErrorBanner': 'Tidak dapat terhubung ke server. Perjalananmu aman — silakan coba lagi.',
'dashboard.retry': 'Coba lagi',
'dashboard.toast.created': 'Perjalanan berhasil dibuat!',
'dashboard.toast.createError': 'Gagal membuat perjalanan',
'dashboard.toast.updated': 'Perjalanan diperbarui!',
+3
View File
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Passato',
'dashboard.status.daysLeft': '-{count} giorni',
'dashboard.toast.loadError': 'Impossibile caricare i viaggi',
'dashboard.loadErrorBanner':
"Impossibile raggiungere il server. I tuoi viaggi sono al sicuro — riprova.",
'dashboard.retry': 'Riprova',
'dashboard.toast.created': 'Viaggio creato con successo!',
'dashboard.toast.createError': 'Impossibile creare il viaggio',
'dashboard.toast.updated': 'Viaggio aggiornato!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': '過去',
'dashboard.status.daysLeft': '残り{count}日',
'dashboard.toast.loadError': '旅行の読み込みに失敗しました',
'dashboard.loadErrorBanner': 'サーバーに接続できませんでした。旅行のデータは安全に保存されています — もう一度お試しください。',
'dashboard.retry': '再試行',
'dashboard.toast.created': '旅行を作成しました!',
'dashboard.toast.createError': '旅行の作成に失敗しました',
'dashboard.toast.updated': '旅行を更新しました!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': '지난 여행',
'dashboard.status.daysLeft': '{count}일 남음',
'dashboard.toast.loadError': '여행 불러오기 실패',
'dashboard.loadErrorBanner': '서버에 연결할 수 없습니다. 여행 정보는 안전하게 보관되어 있으니 잠시 후 다시 시도해 주세요.',
'dashboard.retry': '다시 시도',
'dashboard.toast.created': '여행이 생성되었습니다!',
'dashboard.toast.createError': '여행 생성 실패',
'dashboard.toast.updated': '여행이 업데이트되었습니다!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Afgelopen',
'dashboard.status.daysLeft': 'nog {count} dagen',
'dashboard.toast.loadError': 'Reizen laden mislukt',
'dashboard.loadErrorBanner': 'De server is niet bereikbaar. Je reizen zijn veilig — probeer het opnieuw.',
'dashboard.retry': 'Opnieuw proberen',
'dashboard.toast.created': 'Reis aangemaakt!',
'dashboard.toast.createError': 'Reis aanmaken mislukt',
'dashboard.toast.updated': 'Reis bijgewerkt!',
+2
View File
@@ -39,6 +39,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Zakończona',
'dashboard.status.daysLeft': '{count} dni do końca',
'dashboard.toast.loadError': 'Nie udało się załadować podróży',
'dashboard.loadErrorBanner': 'Nie udało się połączyć z serwerem. Twoje podróże są bezpieczne — spróbuj ponownie.',
'dashboard.retry': 'Spróbuj ponownie',
'dashboard.toast.created': 'Podróż została utworzona pomyślnie!',
'dashboard.toast.createError': 'Nie udało się utworzyć podróży',
'dashboard.toast.updated': 'Podróż została zaktualizowana!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Прошло',
'dashboard.status.daysLeft': 'осталось {count} дн.',
'dashboard.toast.loadError': 'Не удалось загрузить поездки',
'dashboard.loadErrorBanner': 'Не удалось подключиться к серверу. Ваши поездки в безопасности — попробуйте снова.',
'dashboard.retry': 'Повторить',
'dashboard.toast.created': 'Поездка создана!',
'dashboard.toast.createError': 'Не удалось создать поездку',
'dashboard.toast.updated': 'Поездка обновлена!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Geçmiş',
'dashboard.status.daysLeft': '{count} gün kaldı',
'dashboard.toast.loadError': 'Seyahatler yüklenemedi',
'dashboard.loadErrorBanner': 'Sunucuya ulaşılamadı. Seyahatleriniz güvende — lütfen tekrar deneyin.',
'dashboard.retry': 'Tekrar dene',
'dashboard.toast.created': 'Seyahat oluşturuldu!',
'dashboard.toast.createError': 'Seyahat oluşturulamadı',
'dashboard.toast.updated': 'Seyahat güncellendi!',
+3
View File
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Минуло',
'dashboard.status.daysLeft': 'залишилось {count} дн.',
'dashboard.toast.loadError': 'Не вдалося завантажити поїздки',
'dashboard.loadErrorBanner':
"Не вдалося з'єднатися із сервером. Ваші поїздки в безпеці — будь ласка, спробуйте ще раз.",
'dashboard.retry': 'Спробувати ще раз',
'dashboard.toast.created': 'Поїздка створена!',
'dashboard.toast.createError': 'Не вдалося створити поїздку',
'dashboard.toast.updated': 'Поїздка оновлена!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': '已結束',
'dashboard.status.daysLeft': '還剩 {count} 天',
'dashboard.toast.loadError': '載入旅行失敗',
'dashboard.loadErrorBanner': '無法連線到伺服器。你的旅行資料安全無虞——請稍後再試。',
'dashboard.retry': '重試',
'dashboard.toast.created': '旅行建立成功!',
'dashboard.toast.createError': '建立旅行失敗',
'dashboard.toast.updated': '旅行已更新!',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': '已结束',
'dashboard.status.daysLeft': '还剩 {count} 天',
'dashboard.toast.loadError': '加载旅行失败',
'dashboard.loadErrorBanner': '无法连接到服务器。你的旅行数据安然无恙——请稍后重试。',
'dashboard.retry': '重试',
'dashboard.toast.created': '旅行创建成功!',
'dashboard.toast.createError': '创建旅行失败',
'dashboard.toast.updated': '旅行已更新!',