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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.png' {
|
||||
const value: number;
|
||||
export default value;
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user