Implement journey ranking and improve UI presentation

Add `rankJourneys` utility to score connections based on arrival fit,
directness, and duration. Update `JourneyList` to display the best option
first with a "Top" badge and allow expanding to see more. Fix HAFAS
parser to include train direction in journey details. Limit API result
count to 5 and add TypeScript declaration for PNG assets.
This commit is contained in:
2026-05-14 17:05:56 +02:00
parent 0501d66fdc
commit 04e793cbb8
10 changed files with 168 additions and 18 deletions
+45 -1
View File
@@ -1,5 +1,22 @@
// Tests for core utilities
import { calculateCountdown } from '@timetoleave/core';
import { calculateCountdown, rankJourneys } from '@timetoleave/core';
import type { Journey } from '@timetoleave/core';
function journey(overrides: Partial<Journey>): Journey {
return {
id: 'journey',
sD: new Date('2025-01-01T10:00:00Z'),
rD: new Date('2025-01-01T10:00:00Z'),
sA: new Date('2025-01-01T11:00:00Z'),
rA: new Date('2025-01-01T11:00:00Z'),
delay: 0,
platform: '1',
changes: 0,
trains: ['REX1 -> Wr. Neustadt Hbf'],
cancelled: false,
...overrides,
};
}
describe('core utilities', () => {
describe('calculateCountdown', () => {
@@ -77,4 +94,31 @@ describe('core utilities', () => {
expect(result.color).toBe('green');
});
});
describe('rankJourneys', () => {
it('ranks the connection closest to the target arrival highest', () => {
const target = new Date('2025-01-01T11:00:00Z');
const early = journey({ id: 'early', rA: new Date('2025-01-01T10:40:00Z') });
const close = journey({ id: 'close', rA: new Date('2025-01-01T10:58:00Z'), changes: 1 });
const shortButLate = journey({
id: 'late',
rD: new Date('2025-01-01T10:40:00Z'),
rA: new Date('2025-01-01T11:05:00Z'),
});
const ranked = rankJourneys([early, shortButLate, close], target);
expect(ranked[0].journey.id).toBe('close');
});
it('uses directness and duration as tie breakers after arrival fit', () => {
const target = new Date('2025-01-01T11:00:00Z');
const oneChange = journey({ id: 'change', changes: 1 });
const direct = journey({ id: 'direct', changes: 0 });
const ranked = rankJourneys([oneChange, direct], target);
expect(ranked[0].journey.id).toBe('direct');
});
});
});
+4
View File
@@ -0,0 +1,4 @@
declare module '*.png' {
const value: number;
export default value;
}
+37 -9
View File
@@ -1,5 +1,6 @@
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { formatDuration, formatDistance } from '@timetoleave/core';
import { useMemo, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { formatDuration, formatDistance, rankJourneys } from '@timetoleave/core';
import type { Journey, Station, WalkRoute } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors';
@@ -33,8 +34,14 @@ export function JourneyList({
origin,
colors,
}: Props) {
const [expanded, setExpanded] = useState(false);
const walkDurationMs = showWalkingOption ? (walkRoute?.duration ?? 0) * 1000 : 0;
const targetArrivalTime = eventTime.getTime() - arrivalBufferMinutes * 60_000;
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60_000);
const rankedJourneys = useMemo(
() => rankJourneys(journeys, targetArrivalTime, walkDurationMs),
[journeys, targetArrivalTime, walkDurationMs],
);
const visibleJourneys = expanded ? rankedJourneys : rankedJourneys.slice(0, 1);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
@@ -52,9 +59,16 @@ export function JourneyList({
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
</Text>
) : (
journeys.map((j) => {
<TouchableOpacity
activeOpacity={rankedJourneys.length > 1 ? 0.75 : 1}
onPress={() => rankedJourneys.length > 1 && setExpanded((current) => !current)}
accessibilityRole={rankedJourneys.length > 1 ? 'button' : undefined}
accessibilityLabel={expanded ? 'Weniger Zugverbindungen anzeigen' : 'Alle Zugverbindungen anzeigen'}
>
{visibleJourneys.map(({ journey: j }, index) => {
const finalArrival = new Date(j.rA.getTime() + walkDurationMs);
const arrivesTooLate = finalArrival.getTime() > targetArrivalTime;
const arrivesTooLate = finalArrival.getTime() > targetArrivalTime.getTime();
const durationMinutes = Math.max(0, Math.round((j.rA.getTime() - j.rD.getTime()) / 60_000));
return (
<View
@@ -66,9 +80,12 @@ export function JourneyList({
]}
>
<View style={styles.row}>
<Text style={[styles.line, { color: colors.text }]}>
<Text style={[styles.line, { color: colors.text }]} numberOfLines={2}>
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
</Text>
{index === 0 && (
<Text style={[styles.bestBadge, { backgroundColor: colors.accent }]}>Top</Text>
)}
{j.delay > 0 && (
<Text style={[styles.delayBadge, { backgroundColor: colors.error }]}>+{j.delay} min</Text>
)}
@@ -84,6 +101,9 @@ export function JourneyList({
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
{' '}({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
</Text>
<Text style={[styles.detail, { color: colors.subtext }]}>
Dauer: {durationMinutes} min
</Text>
{walkDurationMs > 0 && (
<Text style={[styles.detail, { color: arrivesTooLate ? colors.error : colors.subtext }]}>
Ziel: {finalArrival.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
@@ -92,7 +112,13 @@ export function JourneyList({
)}
</View>
);
})
})}
{rankedJourneys.length > 1 && (
<Text style={[styles.expandHint, { color: colors.accent }]}>
{expanded ? 'Weniger Verbindungen anzeigen' : `${rankedJourneys.length - 1} weitere Verbindungen anzeigen`}
</Text>
)}
</TouchableOpacity>
)}
{showWalkingOption && walkRoute && (
@@ -127,11 +153,13 @@ const styles = StyleSheet.create({
empty: { fontSize: 14 },
card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
lateCard: { opacity: 0.55 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
line: { fontSize: 16, fontWeight: '600' },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
line: { flex: 1, fontSize: 16, fontWeight: '600' },
bestBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, overflow: 'hidden' },
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
cancelBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
detail: { fontSize: 13, marginTop: 6 },
expandHint: { fontSize: 13, fontWeight: '600', marginTop: 2, marginBottom: 12, textAlign: 'center' },
walkTitle: { fontSize: 15, fontWeight: '600', marginBottom: 8 },
walkLabel: { fontSize: 14, fontWeight: '500' },
walkValue: { fontSize: 14, fontWeight: '600' },
+2 -1
View File
@@ -9,6 +9,7 @@ import { AddEventScreen } from '../screens/AddEventScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
import type { RootStack } from '../types/navigation';
import navLogo from '../../assets/nav-logo.png';
// ── Root Stack ──
@@ -31,7 +32,7 @@ export default function AppNavigator() {
headerTintColor: '#F4F1EA',
headerTitle: () => (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Image source={require('../../assets/nav-logo.png')} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="contain" />
<Image source={navLogo} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="contain" />
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Time To Leave</Text>
</View>
),
+11 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
FlatList,
RefreshControl,
@@ -56,8 +56,14 @@ export function EventListScreen({ navigation }: ScreenProps) {
setRefreshing(false);
};
const upcomingEvent = useMemo(() => {
const now = Date.now();
return events
.filter((event) => event.eventTime.getTime() >= now)
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime())[0] ?? null;
}, [events]);
const renderItem = ({ item }: { item: CalendarEvent }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const countdown = calculateCountdown(item.eventTime);
// Derive a simple status — journeys aren't loaded on the list screen for MVP
@@ -108,10 +114,10 @@ export function EventListScreen({ navigation }: ScreenProps) {
);
};
if (events.length === 0) {
if (!upcomingEvent) {
return (
<View style={[styles.center, { backgroundColor: colors.background }]}>
<Text style={[styles.empty, { color: colors.subtext }]}>Keine Termine</Text>
<Text style={[styles.empty, { color: colors.subtext }]}>Keine kommenden Termine</Text>
<TouchableOpacity
style={styles.addBtn}
onPress={() => navigation.navigate('AddEvent')}
@@ -139,7 +145,7 @@ export function EventListScreen({ navigation }: ScreenProps) {
</TouchableOpacity>
</View>
<FlatList
data={events}
data={[upcomingEvent]}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
+1 -1
View File
@@ -181,7 +181,7 @@ export async function POST(request: NextRequest) {
// Cap TripSearch results at 10
if (svcReq.meth === "TripSearch" && svcReq.req && typeof svcReq.req.numF !== "undefined") {
svcReq.req.numF = Math.min(Number(svcReq.req.numF), 10);
svcReq.req.numF = Math.min(Number(svcReq.req.numF), 5);
}
const controller = new AbortController();
+5 -1
View File
@@ -37,7 +37,11 @@ export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate:
const delay = Math.max(0, Math.round((rD.getTime() - sD.getTime()) / 60000));
const trains: string[] = (con.secL ?? [])
.filter((s: RawJson) => s.jny)
.map((s: RawJson) => s.jny?.stopL?.[0]?.name ?? "")
.map((s: RawJson) => {
const name = s.jny?.stopL?.[0]?.name ?? "";
const direction = s.jny?.dirTxt ?? s.jny?.dir ?? "";
return name && direction ? `${name} -> ${direction}` : name;
})
.filter(Boolean);
return {
+1
View File
@@ -8,3 +8,4 @@ export * from './status-utils';
export * from './hafas-time';
export * from './hafas-parser';
export * from './defaults';
export * from './journey-scoring';
+61
View File
@@ -0,0 +1,61 @@
import type { Journey } from "./types";
export interface RankedJourney {
journey: Journey;
score: number;
}
function durationMinutes(journey: Journey): number {
return Math.max(0, (journey.rA.getTime() - journey.rD.getTime()) / 60_000);
}
function arrivalDeltaMinutes(journey: Journey, targetArrivalTime: Date, finalLegDurationMs: number): number {
const finalArrival = journey.rA.getTime() + finalLegDurationMs;
return Math.abs(finalArrival - targetArrivalTime.getTime()) / 60_000;
}
/**
* Rank journeys by arrival fit, transfer count, and duration.
*
* Arrival fit is weighted highest because users asked for connections close to
* the planned arrival. Direct connections are favored next, then shorter trips.
* Late arrivals are heavily penalized so an on-time option usually wins.
*/
export function rankJourneys(
journeys: Journey[],
targetArrivalTime: Date,
finalLegDurationMs = 0,
): RankedJourney[] {
const durations = journeys.map(durationMinutes);
const minDuration = Math.min(...durations);
const maxDuration = Math.max(...durations);
const durationRange = Math.max(1, maxDuration - minDuration);
return journeys
.map((journey): RankedJourney => {
const finalArrivalMs = journey.rA.getTime() + finalLegDurationMs;
const targetArrivalMs = targetArrivalTime.getTime();
const deltaMinutes = Math.abs(finalArrivalMs - targetArrivalMs) / 60_000;
const lateMinutes = Math.max(0, (finalArrivalMs - targetArrivalMs) / 60_000);
const duration = durationMinutes(journey);
const arrivalScore = Math.max(0, 100 - deltaMinutes * 4) - lateMinutes * 10;
const changesScore = Math.max(0, 100 - journey.changes * 35);
const durationScore = 100 - ((duration - minDuration) / durationRange) * 100;
const cancelledPenalty = journey.cancelled ? 1_000 : 0;
const score = arrivalScore * 0.55 + changesScore * 0.3 + durationScore * 0.15 - cancelledPenalty;
return { journey, score };
})
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const arrivalTie = arrivalDeltaMinutes(a.journey, targetArrivalTime, finalLegDurationMs)
- arrivalDeltaMinutes(b.journey, targetArrivalTime, finalLegDurationMs);
if (arrivalTie !== 0) return arrivalTie;
if (a.journey.changes !== b.journey.changes) return a.journey.changes - b.journey.changes;
return durationMinutes(a.journey) - durationMinutes(b.journey);
});
}
+1
View File
@@ -36,6 +36,7 @@ export interface Journey {
delay: number; // delay in minutes
platform: string;
changes: number;
/** Train legs, optionally including direction text when supplied by HAFAS. */
trains: string[];
cancelled: boolean;
}