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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './status-utils';
|
||||
export * from './hafas-time';
|
||||
export * from './hafas-parser';
|
||||
export * from './defaults';
|
||||
export * from './journey-scoring';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user