katja's git: oeffisearch

fast and simple tripplanner

commit 3a3a51ef76f17c3c5f8da37ef960c376738104db
parent 82b6a3d784c7de061dc353b207a9e48d27aee964
Author: Katja (ctucx) <git@ctu.cx>
Date: Sat, 19 Apr 2025 00:23:56 +0200

replace html-tables with css grids
13 files changed, 298 insertions(+), 334 deletions(-)
M
src/departuresView.js
|
95
++++++++++++++++++++++++++++++++++++-------------------------------------------
M
src/journeyView.js
|
124
++++++++++++++++++++++++++++++++++++++-----------------------------------------
M
src/journeysView.js
|
55
++++++++++++++++++++++++++-----------------------------
M
src/styles.js
|
4
++--
M
src/styles/base.css
|
1
+
D
src/styles/card.css
|
81
-------------------------------------------------------------------------------
M
src/styles/departuresView.css
|
10
++++------
M
src/styles/icons.css
|
12
------------
M
src/styles/journeyView.css
|
48
+++++++++++++++++++++++++++++++++++++-----------
M
src/styles/journeysView.css
|
14
++++++++++++++
A
src/styles/table.css
|
35
+++++++++++++++++++++++++++++++++++
M
src/templates.js
|
2
+-
M
src/tripView.js
|
151
+++++++++++++++++++++++++++++++++++++++----------------------------------------
diff --git a/src/departuresView.js b/src/departuresView.js
@@ -7,13 +7,13 @@ import { processLeg } from './app_functions.js';
 import { getHafasClient } from './hafasClient.js';
 import { t } from './languages.js';
 
-import { headerStyles, cardStyles, departuresViewStyles } from './styles.js';
+import { headerStyles, tableStyles, departuresViewStyles } from './styles.js';
 
 class DeparturesView extends BaseView {
 	static styles = [
 		super.styles,
 		headerStyles,
-		cardStyles,
+		tableStyles,
 		departuresViewStyles
 	];
 

@@ -54,56 +54,6 @@ class DeparturesView extends BaseView {
 		if (previous.has('isOffline') && this.viewState === null) await this.updateViewState();
 	}
 
-	renderView = () => [
-		html`
-		<div class="header-container">
-			<header>
-				<a class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a>
-				<div class="container">
-					<h3>Departures from ${this.viewState !== null ? this.viewState.name : '...'}</h3>
-				</div>
-				<a class="icon-reload ${!this.isUpdating ? '' : 'spinning'} ${!this.isOffline ? '' : 'invisible'}" title="${t("reload")}" @click=${this.updateViewState}></a>
-			</header>
-		</div>
-		`,
-
-		this.viewState !== null ? html`
-		<div class="container">
-			<div class="card">
-			<table>
-				<thead>
-					<tr>
-						<th>Time</th>
-						<th class="station"></th>
-						<th>${t('platform')}</th>
-					</tr>
-				</thead>
-				<tbody>
-					${(this.viewState.departures || []).map(departure => html`
-						<tr @click=${() => window.location = `#/t/${this.profile}/${departure.tripId}`}>
-							<td class="${departure.cancelled ? 'cancelled' : nothing}">
-								<span>${timeTemplate(departure)}</span>
-							</td>
-							<td class="${departure.cancelled ? 'cancelled' : nothing}">
-								<span>${departure.line.name}${departure.direction ? html` → ${departure.direction}` : nothing}</span>
-							</td>
-							${departure.cancelled ? html`
-								<td><span class="cancelled-text">${t('cancelled-ride')}</span></td>
-							` : html`
-								<td>${platformTemplate(departure)}</td>
-							`}
-						</tr>
-					`)}
-				</tbody>
-			</table>
-			</div>
-		</div>
-		<footer-component></footer-component>
-		` : !this.isOffline ?
-		html`<div class="spinner"></div>`
-		: html`<div class="offline"></div>`
-	];
-
 	updateViewState = async () => {
 		if (this.isOffline !== false) return;
 

@@ -131,6 +81,47 @@ class DeparturesView extends BaseView {
 		}
 		this.isUpdating = false;
 	}
+
+	renderView = () => [
+		html`
+		<div class="header-container">
+			<header>
+				<a class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a>
+				<div class="container">
+					<h3>Departures from ${this.viewState !== null ? this.viewState.name : '...'}</h3>
+				</div>
+				<a class="icon-reload ${!this.isUpdating ? '' : 'spinning'} ${!this.isOffline ? '' : 'invisible'}" title="${t("reload")}" @click=${this.updateViewState}></a>
+			</header>
+		</div>
+		`,
+
+		this.viewState !== null ? html`
+		<div class="container">
+			<div class="table" style="grid-template-columns: 1fr 3.5fr 1fr">
+				<div class="row head">
+					<span>Time</span>
+					<span></span>
+					<span>${t('platform')}</span>
+				</div>
+				${(this.viewState.departures || []).map(departure => html`
+				<a class="row" href="#/t/${this.profile}/${departure.tripId}">
+					<span class="${departure.cancelled ? 'cancelled' : nothing}">${timeTemplate(departure)}</span>
+					<span style="justify-content:unset" class="${departure.cancelled ? 'cancelled' : nothing}">${departure.line.name}${departure.direction ? html` → ${departure.direction}` : nothing}</span>
+					${departure.cancelled ? html`
+					<span class="cancelled-text">${t('cancelled-ride')}</span>
+					` : html`
+					<span>${platformTemplate(departure)}</span>
+					`}
+				</a>
+				`)}
+				</tbody>
+			</div>
+		</div>
+		<footer-component></footer-component>
+		` : !this.isOffline ?
+		html`<div class="spinner"></div>`
+		: html`<div class="offline"></div>`
+	];
 }
 
 customElements.define('departures-view', DeparturesView);
diff --git a/src/journeyView.js b/src/journeyView.js
@@ -11,13 +11,13 @@ import { remarksModal, platformTemplate, stopTemplate, timeTemplate } from './te
 import { formatPoint, formatDuration, formatPrice, formatTrainTypes, formatLineAdditionalName, formatLineDisplayName } from './formatters.js';
 import { cachedCoachSequence } from './coach-sequence/index.js';
 
-import { headerStyles, cardStyles, journeyViewStyles } from './styles.js';
+import { headerStyles, tableStyles, journeyViewStyles } from './styles.js';
 
 class JourneyView extends BaseView {
 	static styles = [
 		super.styles,
 		headerStyles,
-		cardStyles,
+		tableStyles,
 		journeyViewStyles
 	];
 

@@ -61,30 +61,36 @@ class JourneyView extends BaseView {
 
 	renderView = () => [
 		html`
-		<div class="header-container">
-			<header>
-				<a id="back" class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a>
-				<div class="container">
-					<a class="icon-reload ${this.isUpdating ? 'spinning' : ''} ${!this.isOffline ? '' : 'invisible'}" title="${t("reload")}" @click=${this.refreshJourney}></a>
-					${this.viewState !== null ? html`
-					<h3>${formatPoint(this.viewState.legs[0].origin)} → ${formatPoint(this.viewState.legs[this.viewState.legs.length - 1].destination)}</h3>
-					<p><b>${t('duration')}: ${formatDuration(this.viewState.duration)} | ${t('changes')}: ${this.viewState.changes-1} | ${t('date')}: ${this.viewState.legs[0].plannedDeparture.formatDate()}${this.settingsState.showPrices && this.viewState.price ? html` | ${t('price')}: <td><span>${formatPrice(this.viewState.price)}</span></td>` : nothing}</b></p>
-					` : html`
-					<h3>... → ...</h3>
-					<p><b>${t('duration')}: ... | ${t('changes')}: ... | ${t('date')}: ...</b></p>
-					`}
-				</div>
-				<a class="icon-dots" title="${t("more")}" @click=${this.moreModal}></a>
-			</header>
-		</div>
-		`,
-		this.viewState !== null ? html`
-		<div class="container journeyView">
-			${this.viewState.legs.map(leg => this.legTemplate(leg))}
-		</div>
-		<footer-component></footer-component>
+			<div class="header-container">
+				<header>
+					<a id="back" class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a>
+					<div class="container">
+						<a class="icon-reload ${this.isUpdating ? 'spinning' : ''} ${!this.isOffline ? '' : 'invisible'}" title="${t("reload")}" @click=${this.refreshJourney}></a>
+						${this.viewState !== null ? html`
+							<h3>${formatPoint(this.viewState.legs[0].origin)} → ${formatPoint(this.viewState.legs[this.viewState.legs.length - 1].destination)}</h3>
+							<p><b>
+								${t('duration')}: ${formatDuration(this.viewState.duration)} |
+								${t('changes')}: ${this.viewState.changes-1} |
+								${t('date')}: ${this.viewState.legs[0].plannedDeparture.formatDate()}
+								${this.settingsState.showPrices && this.viewState.price ? 
+									html` | ${t('price')}: <span>${formatPrice(this.viewState.price)}</span>`
+								: nothing}
+							</b></p>
+						` : html`
+							<h3>... → ...</h3>
+							<p><b>${t('duration')}: ... | ${t('changes')}: ... | ${t('date')}: ...</b></p>
+						`}
+					</div>
+					<a class="icon-dots" title="${t("more")}" @click=${this.moreModal}></a>
+				</header>
+			</div>
+		`, this.viewState !== null ? html`
+			<div class="container">
+				${this.viewState.legs.map(leg => this.legTemplate(leg))}
+			</div>
+			<footer-component></footer-component>
 		` : html`
-		<div class="spinner"></div>
+			<div class="spinner"></div>
 		`,
 	];
 

@@ -98,45 +104,35 @@ class JourneyView extends BaseView {
 		} else {
 			return html`
 				<div class="card">
-					<table>
-						<thead>
-							<tr>
-								<td colspan="4">
-									<div class="flex-center"><a href="#/t/${this.profile}/${leg.tripId}">${formatLineDisplayName(leg.line)}${leg.direction ? html` → ${leg.direction}` : nothing}</a>
-									${leg.cancelled ? html`<b class="cancelled-text">${t('cancelled-ride')}</b>` : nothing}
-									${leg.remarks.length !== 0 ? html`
-										<a class="link ${leg.remarksIcon}" @click=${() => remarksModal(this, leg.remarks)}></a>
-									` : nothing}</div>
-								</td>
-							</tr>
-							<tr>
-								<td colspan="4">
-									<div class="train-details flex-center">
-										${formatLineAdditionalName(leg.line) ? html`<div>Trip: ${formatLineAdditionalName(leg.line)}</div>` : nothing}
-										${leg.line.trainType                 ? html`<div>${t('trainType')}: ${leg.line.trainType}</div>` : nothing}
-										${(leg.arrival && leg.departure)     ? html`<div>${t('duration')}: ${formatDuration(leg.arrival - leg.departure)}</div>` : nothing}
-										${leg.loadFactor                     ? html`<div>${t(`load-${leg.loadFactor}`)}</div>` : nothing}
-									</div>
-								</td>
-							</tr>
-							<tr>
-								<th>${t('arrival')}</th>
-								<th>${t('departure')}</th>
-								<th class="station">${t('station')}</th>
-								<th>${t('platform')}</th>
-							</tr>
-						</thead>
-						<tbody>
-							${(leg.stopovers || []).map(stop => html`
-								<tr class="stop ${stop.cancelled ? 'cancelled' : nothing}">
-									<td><span>${timeTemplate(stop, 'arrival')}</span></td>
-									<td><span>${timeTemplate(stop, 'departure')}</span></td>
-									<td>${stopTemplate(this.profile, stop.stop)}</td>
-									<td><span>${platformTemplate(stop)}</span></td>
-								</tr>
-							`)}
-						</tbody>
-					</table>
+					<div class="head flex-center">
+						<a href="#/t/${this.profile}/${leg.tripId}">${formatLineDisplayName(leg.line)}${!leg.direction ? nothing : html` → ${leg.direction}`}</a>
+						${!leg.cancelled ? nothing : html`<b class="cancelled-text">${t('cancelled-ride')}</b>`}
+						${leg.remarks.length === 0 ? nothing : html`
+							<a class="${leg.remarksIcon}" @click=${() => remarksModal(this, leg.remarks)}></a>
+						`}
+					</div>
+					<div class="head flex-center">
+						${formatLineAdditionalName(leg.line) === null ? nothing : html`<span>Trip: ${formatLineAdditionalName(leg.line)}</span>`}
+						${!leg.line.trainType                         ? nothing : html`<span>${t('trainType')}: ${leg.line.trainType}</span>`}
+						${(!leg.arrival && !leg.departure)            ? nothing : html`<span>${t('duration')}: ${formatDuration(leg.arrival - leg.departure)}</span>`}
+						${!leg.loadFactor                             ? nothing : html`<span>${t(`load-${leg.loadFactor}`)}</span>`}
+					</div>
+					<div class="table">
+						<div class="row head">
+							<span>${t('arrival')}</span>
+							<span>${t('departure')}</span>
+							<span>${t('station')}</span>
+							<span>${t('platform')}</span>
+						</div>
+						${(leg.stopovers || []).map(stop => html`
+						<div class="row">
+							<span>${timeTemplate(stop, 'arrival')}</span>
+							<span>${timeTemplate(stop, 'departure')}</span>
+							<span>${stopTemplate(this.profile, stop.stop)}</span>
+							<span>${platformTemplate(stop)}</span>
+						</div>
+						`)}
+					</div>
 				</div>
 			`;
 		}
diff --git a/src/journeysView.js b/src/journeysView.js
@@ -7,7 +7,7 @@ import { timeTemplate } from './templates.js';
 import { settings } from './settings.js';
 import { t } from './languages.js';
 
-import { headerStyles, cardStyles, journeysViewStyles } from './styles.js';
+import { headerStyles, tableStyles, journeysViewStyles } from './styles.js';
 
 import { JourneysCanvas } from './journeysCanvas.js';
 

@@ -15,7 +15,7 @@ export class JourneysView extends JourneysCanvas {
 	static styles = [
 		super.styles,
 		headerStyles,
-		cardStyles,
+		tableStyles,
 		journeysViewStyles
 	];
 

@@ -117,24 +117,21 @@ export class JourneysView extends JourneysCanvas {
 			<div class="container">
 				${!this.viewState.earlierRef ? nothing : html`
 				<a class="arrowButton icon-arrow2 flipped flex-center" title="${t('label_earlier')}" @click=${() => this.moreJourneys('earlier')}></a>
+				`}
 
-				<div class="card">
-				<table>
-					<thead>
-						<tr>
-							<th>${t('departure')}</th>
-							<th>${t('arrival')}</th>
-							<th>${t('duration')}</th>
-							<th>${t('changes')}</th>
-							<th>${t('products')}</th>
-							${this.settingsState.showPrices && this.viewState.profile === 'db' ? html`<th>${t('price')}</th>` : nothing}
-							<th></th>
-						</tr>
-					</thead>
-					<tbody>
-						${this.viewState.journeys.map(journey => this.journeyTemplate(journey))}
-					</tbody>
-				</table>
+				<div class="table" style="grid-template-columns: repeat(${this.settingsState.showPrices && this.viewState.profile === 'db' ? '6' : '5'}, 1fr) .3fr">
+					<div class="row head">
+						<span>${t('departure')}</span>
+						<span>${t('arrival')}</span>
+						<span>${t('duration')}</span>
+						<span>${t('changes')}</span>
+						<span>${t('products')}</span>
+						${this.settingsState.showPrices && this.viewState.profile === 'db' ? html`
+						<span>${t('price')}</spanv>
+						` : nothing}
+						<span></span>
+					</div>
+					${this.viewState.journeys.map(journey => this.journeyTemplate(journey))}
 				</div>
 
 				${!this.viewState.laterRef ? nothing : html`

@@ -151,21 +148,21 @@ export class JourneysView extends JourneysCanvas {
 		const lastLeg  = journey.legs[journey.legs.length - 1];
 
 		return html`
-			<tr @click=${() => window.location = `#/j/${this.viewState.profile}/${journey.refreshToken}`}>
-				<td class="${!journey.cancelled ? nothing : 'cancelled'}">${timeTemplate(firstLeg, 'departure')}</td>
+			<a class="row" href="#/j/${this.viewState.profile}/${journey.refreshToken}">
+				<span class="${!journey.cancelled ? nothing : 'cancelled'}">${timeTemplate(firstLeg, 'departure')}</span>
 				${journey.cancelled ? html`
-				<td><span class="cancelled-text">${t('cancelled-ride')}</span></td>
+				<span class="cancelled-text">${t('cancelled-ride')}</span>
 				` : html`
-				<td>${timeTemplate(lastLeg, 'arrival')}</td>
+				<span>${timeTemplate(lastLeg, 'arrival')}</span>
 				`}
-				<td class="${!journey.cancelled ? nothing : 'cancelled'}" title="${journey.changesDuration > 0 ? 'including '+formatDuration(journey.changesDuration)+' transfer durations' : ''}">${formatDuration(journey.duration)}</td>
-				<td>${journey.changes-1}</td>
-				<td>${journey.products}</td>
+				<span class="${!journey.cancelled ? nothing : 'cancelled'}" title="${journey.changesDuration > 0 ? 'including '+formatDuration(journey.changesDuration)+' transfer durations' : ''}">${formatDuration(journey.duration)}</span>
+				<span>${journey.changes-1}</span>
+				<span>${journey.products}</span>
 				${this.settingsState.showPrices && this.viewState.profile === 'db' ? html`
-				<td>${formatPrice(journey.price)}</td>
+				<span>${formatPrice(journey.price)}</span>
 				` : nothing}
-				<td><a class="icon-arrow1"></a></td>
-			</tr>
+				<span><i class="icon-arrow1"></i></span>
+			</a>
 		`;
 	}
 
diff --git a/src/styles.js b/src/styles.js
@@ -2,7 +2,7 @@ import baseStyles from './styles/base.css' assert { type: 'css' };
 import flexboxStyles from './styles/flexbox.css' assert { type: 'css' };
 import buttonInputStyles from './styles/buttonInput.css' assert { type: 'css' };
 import headerStyles from './styles/header.css' assert { type: 'css' };
-import cardStyles from './styles/card.css' assert { type: 'css' };
+import tableStyles from './styles/table.css' assert { type: 'css' };
 import iconStyles from './styles/icons.css' assert { type: 'css' };
 import overlaysStyles from './styles/overlays.css' assert { type: 'css' };
 import searchViewStyles from './styles/searchView.css' assert { type: 'css' };

@@ -12,5 +12,5 @@ import departuresViewStyles from './styles/departuresView.css' assert { type: 'c
 import settingsViewStyles from './styles/settingsView.css' assert { type: 'css' };
 import footerStyles from './styles/footer.css' assert { type: 'css' };
 
-export { baseStyles, flexboxStyles, buttonInputStyles, headerStyles, cardStyles, iconStyles, overlaysStyles, footerStyles };
+export { baseStyles, flexboxStyles, buttonInputStyles, headerStyles, tableStyles, iconStyles, overlaysStyles, footerStyles };
 export { searchViewStyles, journeysViewStyles, journeyViewStyles, departuresViewStyles, settingsViewStyles };
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -20,6 +20,7 @@ body {
 
 a {
 	color: inherit;
+	text-decoration: none;
 }
 
 .container {
diff --git a/src/styles/card.css b/src/styles/card.css
@@ -1,81 +0,0 @@
-.card {
-	overflow-x: auto;
-
-	table {
-		border-bottom: 1px solid rgba(0, 0, 0, 0.3);
-		width: 100%;
-		background-color: white;
-		min-width: 390px;
-		max-width: 1000px;
-	}
-
-	table a {
-		padding: 5px 3px;
-		text-decoration: none;
-	}
-	
-	thead {
-		.center {
-			padding: 5px 3px;
-		}
-
-		tr:not(:last-child) {
-			background-color: #eee;
-		}
-	}
-
-	tbody {
-		tr {
-			cursor: pointer;
-			border-top: 1px solid #ccc;
-		}
-
-		tr:hover {
-			background-color: #ddd;
-		}
-
-		tr:hover td {
-			background-color: transparent;
-		}
-	}
-
-	td, th {
-		text-align: center;
-		overflow: hidden;
-	}
-
-	th {
-		padding: 10px 5px;
-	}
-
-	th.station {
-		width: 60%;
-	}
-
-	.train-details {
-		flex-wrap: wrap;
-		padding: 0 !important;
-	
-		div {
-			margin: .4em 2em;
-		}
-	}
-
-	.cancelled {
-		text-decoration-line: line-through;
-	}
-	
-	.cancelled-text {
-		font-weight: bold;
-		color: red !important;
-	}
-}
-
-@media (min-width: 800px) {
-	table {
-		overflow: hidden;
-		border: none;
-		margin: 50px auto;
-		width: 80vw;
-	}
-}
diff --git a/src/styles/departuresView.css b/src/styles/departuresView.css
@@ -1,7 +1,5 @@
-tbody td:nth-child(2) {
-	text-align: unset;
-}
-
-tbody td {
-	padding: 5px 3px;
+@media (min-width: 800px) {
+	.table {
+		margin: 50px auto;
+	}
 }
diff --git a/src/styles/icons.css b/src/styles/icons.css
@@ -6,18 +6,6 @@
 	content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M17.65 6.35A7.96 7.96 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z" fill="white"/></svg>');
 }
 
-.icon-hint {
-	content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m6 6h2V7h-2zm3 8v-2h-1v-4h-3v2h1v2h-1v2z"/></svg>');
-}
-
-.icon-status {
-	content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z"/></svg>');
-}
-
-.icon-warning {
-	content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13 13h-2V7h2m-2 8h2v2h-2m4.73-14H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27z"/></svg>');
-}
-
 .icon-other {
 	content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m1 17h-2v-2h2zm2.07-7.75-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25"/></svg>');
 }
diff --git a/src/styles/journeyView.css b/src/styles/journeyView.css
@@ -1,9 +1,21 @@
-tbody:not(:last-child) {
-	border-bottom: 1px solid rgba(0, 0, 0, .2);
-}
+.card {
+	.flex-center {
+		flex-wrap: wrap;
+		background-color: rgb(238, 238, 238);
+		padding: .5em .2em;
+
+		span {
+			margin: 0 2em;
+		}
+	}
+
+	.table {
+		grid-template-columns: 1fr 1fr 3.5fr 1fr;
 
-thead>tr:nth-child(2) {
-	border-bottom: 2px solid #ccc;
+		.head span {
+			border-top: 2px solid rgb(204, 204, 204);
+		}
+	}
 }
 
 p {

@@ -36,9 +48,24 @@ p.transfer::before {
 	content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="m21.71 11.29-9-9a.996.996 0 0 0-1.41 0l-9 9a.996.996 0 0 0 0 1.41l9 9c.39.39 1.02.39 1.41 0l9-9a.996.996 0 0 0 0-1.41M14 14.5V12h-4v3H8v-4c0-.55.45-1 1-1h5V7.5l3.5 3.5z" fill="white"/></svg>');
 }
 
-.link {
-	vertical-align: bottom;
-	cursor: pointer;
-	max-inline-size: 26px;
+a[class^="icon-"] {
 	margin: 0 .3em;
-}-
\ No newline at end of file
+}
+
+.icon-hint {
+	content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m6 6h2V7h-2zm3 8v-2h-1v-4h-3v2h1v2h-1v2z"/></svg>');
+}
+
+.icon-status {
+	content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z"/></svg>');
+}
+
+.icon-warning {
+	content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M13 13h-2V7h2m-2 8h2v2h-2m4.73-14H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27z"/></svg>');
+}
+
+@media (min-width: 800px) {
+	.card {
+		margin: 50px auto;
+	}
+}
diff --git a/src/styles/journeysView.css b/src/styles/journeysView.css
@@ -1,3 +1,13 @@
+.table {
+	.head span {
+		padding: .65em .2em;
+	}
+
+	span {
+		padding: .45em .2em;
+	}
+}
+
 .icon-table {
 	content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M3 9h14V7H3zm0 4h14v-2H3zm0 4h14v-2H3zm16 0h2v-2h-2zm0-10v2h2V7zm0 6h2v-2h-2z" fill="white" /></svg>');
 }

@@ -6,6 +16,10 @@
 	content: url('data:image/svg+xml;utf8,<svg version="1.1" viewBox="0 0 24 24" height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m11 5v14h2v-14zm-4-2v14h2v-14zm10 4h-2v14h2z" fill="white"/></svg>');
 }
 
+.relative {
+	position: relative;
+}
+
 @media (max-width: 799px) {
 	.arrowButton {
 		margin: 15px auto;
diff --git a/src/styles/table.css b/src/styles/table.css
@@ -0,0 +1,35 @@
+.table {
+	display: grid;
+	overflow-x: auto;
+	background-color: white;
+
+	.row {
+		display: contents;
+	}
+
+	.row:not(.head):hover span {
+		background-color: #ddd;
+	}
+
+	.head span {
+		font-weight: bold;
+		padding: .55em .2em;
+	}
+
+	span {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		padding: .35em .2em;
+		border-bottom: 1px solid rgba(0, 0, 0, 0.3);
+	}
+}
+
+.cancelled {
+	text-decoration-line: line-through;
+}
+
+.cancelled-text {
+	font-weight: bold;
+	color: red !important;
+}
diff --git a/src/templates.js b/src/templates.js
@@ -38,7 +38,7 @@ export const stopTemplate = (profile, stop) => {
 		if (ds100 !== null) stopName += ` (${ds100})`;
 	}
 
-	return html`<a class="flex-center" href="#/d/${profile}/${stop.id}">${stopName}</a>`;
+	return html`<a href="#/d/${profile}/${stop.id}">${stopName}</a>`;
 }
 
 export const platformTemplate = data => {
diff --git a/src/tripView.js b/src/tripView.js
@@ -9,13 +9,13 @@ import { formatDuration, formatLineAdditionalName, formatLineDisplayName, format
 import { getHafasClient } from './hafasClient.js';
 import { t } from './languages.js';
 
-import { headerStyles, cardStyles, journeyViewStyles } from './styles.js';
+import { headerStyles, tableStyles, journeyViewStyles } from './styles.js';
 
 class TripView extends BaseView {
 	static styles = [
 		super.styles,
 		headerStyles,
-		cardStyles,
+		tableStyles,
 		journeyViewStyles
 	];
 

@@ -55,80 +55,6 @@ class TripView extends BaseView {
 		if (previous.has('isOffline') && this.viewState === null) await this.updateViewState();
 	}
 
-	renderView = () => [
-		html`
-		<div class="header-container">
-			<header>
-				<a id="back" class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a>
-				<div class="container">
-					${this.viewState !== null ?
-					html`<h3>Trip of ${formatLineDisplayName(this.viewState.trip.line)} to ${this.viewState.trip.direction}</h3>`
-					: html`<h3>Trip of ...</h3>
-					`}
-				</div>
-				<a class="icon-reload ${this.isUpdating ? 'spinning' : ''} ${!this.isOffline ? '' : 'invisible'}" title="${t("reload")}" @click=${this.updateViewState}></a>
-			</header>
-		</div>
-		`,
-
-		this.viewState !== null ? html`
-		<div class="container">
-			<div class="card">
-				<table>
-					<thead>
-						<tr>
-							<td colspan="4">
-								<div class="center">
-									${this.viewState.trip.bahnExpertUrl ? html`
-									<a href="${this.viewState.trip.bahnExpertUrl}">${formatLineDisplayName(this.viewState.trip.line)}${this.viewState.trip.direction ? html` → ${this.viewState.trip.direction}` : ''}</a>
-									` : html `
-									${formatLineDisplayName(this.viewState.trip.line)}${this.viewState.trip.direction ? html` → ${this.viewState.trip.direction}` : ''}
-									`}
-									${this.viewState.trip.cancelled ? html`<b class="cancelled-text">${t('cancelled-ride')}</b>` : ''}
-									${this.viewState.trip.remarks.length !== 0 ? html`
-									<a class="link ${this.viewState.trip.remarksIcon}" @click=${() => remarksModal(this, this.viewState.trip.remarks)}></a>
-									` : nothing}
-								</div>
-							</td>
-						</tr>
-						<tr>
-							<td colspan="4">
-								<div class="train-details flex-center">
-									${formatLineAdditionalName(this.viewState.trip.line) ? html`<div>Trip: ${formatLineAdditionalName(this.viewState.trip.line)}</div>` : nothing}
-									${this.viewState.trip.line.trainType ? html`<div>Train type: ${this.viewState.trip.line.trainType}</div>` : nothing}
-									<div class="${!this.viewState.trip.cancelled ? nothing : 'cancelled'}">
-										${t('duration')}: ${formatDuration(this.viewState.trip.arrival - (this.viewState.trip.departure ? this.viewState.trip.departure : this.viewState.trip.plannedDeparture))} ${this.viewState.trip.departure ? '' : ('(' + t('planned') + ')')}
-									</div>
-									${this.viewState.trip.loadFactor ? html`<div>${t("load-"+this.viewState.trip.loadFactor)}</div>` : nothing}
-								</div>
-							</td>
-						</tr>
-						<tr>
-							<th>${t('arrival')}</th>
-							<th>${t('departure')}</th>
-							<th class="station">${t('station')}</th>
-							<th>${t('platform')}</th>
-						</tr>
-					</thead>
-					<tbody>
-						${(this.viewState.trip.stopovers || []).map(stop => html`
-							<tr class="${stop.cancelled ? 'cancelled' : ''}">
-								<td>${timeTemplate(stop, 'arrival')}</td>
-								<td>${timeTemplate(stop, 'departure')}</td>
-								<td>${stopTemplate(this.profile, stop.stop)}</td>
-								<td>${platformTemplate(stop)}</td>
-							</tr>
-						`)}
-					</tbody>
-				</table>
-			</div>
-		</div>
-		<footer-component></footer-component>
-		` : !this.isOffline ?
-		html`<div class="spinner"></div>`
-		: html`<div class="offline"></div>`
-	];
-
 	updateViewState = async () => {
 		if (this.isOffline !== false) return;
 

@@ -169,6 +95,79 @@ class TripView extends BaseView {
 		}
 		this.isUpdating = false;
 	}
+
+	renderView = () => [
+		html`
+			<div class="header-container">
+				<header>
+					<a id="back" class="icon-back ${history.length !== 1 ? '': 'invisible'}" title="${t('back')}" @click=${() => history.back()}></a>
+					<div class="container">
+						${this.viewState !== null ? html`
+							<h3>Trip of ${formatLineDisplayName(this.viewState.trip.line)} to ${this.viewState.trip.direction}</h3>
+						` : html`
+							<h3>Trip of ...</h3>
+						`}
+					</div>
+					<a class="icon-reload ${this.isUpdating ? 'spinning' : ''} ${!this.isOffline ? '' : 'invisible'}" title="${t("reload")}" @click=${this.updateViewState}></a>
+				</header>
+			</div>
+		`,
+
+		this.viewState !== null ? html`
+		<div class="container">
+			<div class="card">
+				<div class="head flex-center">
+					${!this.viewState.trip.bahnExpertUrl ? nothing : html`<a href="${this.viewState.trip.bahnExpertUrl}">`}
+					${formatLineDisplayName(this.viewState.trip.line)}${this.viewState.trip.direction ? html` → ${this.viewState.trip.direction}` : ''}
+					${!this.viewState.trip.bahnExpertUrl ? nothing : html`</a>`}
+
+					${!this.viewState.trip.cancelled ? nothing : html`<b class="cancelled-text">${t('cancelled-ride')}</b>`}
+
+					${this.viewState.trip.remarks.length === 0 ? nothing : html`
+					<a class="${this.viewState.trip.remarksIcon}" @click=${() => remarksModal(this, this.viewState.trip.remarks)}></a>
+					`}
+				</div>
+
+				<div class="head flex-center">
+					${formatLineAdditionalName(this.viewState.trip.line) !== "" ? nothing : html`
+						<span>Trip: ${formatLineAdditionalName(this.viewState.trip.line)}</span>
+					`}
+					${!this.viewState.trip.line.trainType ? nothing : html`
+						<span>Train type: ${this.viewState.trip.line.trainType}</span>
+					`}
+					<span class="${!this.viewState.trip.cancelled ? nothing : 'cancelled'}">
+						${t('duration')}:
+						${formatDuration(this.viewState.trip.arrival - (this.viewState.trip.departure ? this.viewState.trip.departure : this.viewState.trip.plannedDeparture))}
+						${this.viewState.trip.departure ? '' : ('(' + t('planned') + ')')}
+					</span>
+					${!this.viewState.trip.loadFactor ? nothing : html`
+					<span>${t("load-"+this.viewState.trip.loadFactor)}</span>
+					`}
+				</div>
+
+				<div class="table">
+					<div class="row head">
+						<span>${t('arrival')}</span>
+						<span>${t('departure')}</span>
+						<span>${t('station')}</span>
+						<span>${t('platform')}</span>
+					</div>
+					${(this.viewState.trip.stopovers || []).map(stop => html`
+					<div class="row ${!stop.cancelled ? '' : 'cancelled'}">
+						<span>${timeTemplate(stop, 'arrival')}</span>
+						<span>${timeTemplate(stop, 'departure')}</span>
+						<span>${stopTemplate(this.profile, stop.stop)}</span>
+						<span>${platformTemplate(stop)}</span>
+					</div>
+					`)}
+				</div>
+			</div>
+		</div>
+		<footer-component></footer-component>
+		` : !this.isOffline ?
+		html`<div class="spinner"></div>`
+		: html`<div class="offline"></div>`
+	];
 }
 
 customElements.define('trip-view', TripView);