diff --git a/apps/mobile/src/__tests__/core-utils.test.ts b/apps/mobile/src/__tests__/core-utils.test.ts index 03f2587..af63220 100644 --- a/apps/mobile/src/__tests__/core-utils.test.ts +++ b/apps/mobile/src/__tests__/core-utils.test.ts @@ -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 { + 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'); + }); + }); }); diff --git a/apps/mobile/src/assets.d.ts b/apps/mobile/src/assets.d.ts new file mode 100644 index 0000000..894d908 --- /dev/null +++ b/apps/mobile/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: number; + export default value; +} diff --git a/apps/mobile/src/components/JourneyList.tsx b/apps/mobile/src/components/JourneyList.tsx index e26676c..0fc75cd 100644 --- a/apps/mobile/src/components/JourneyList.tsx +++ b/apps/mobile/src/components/JourneyList.tsx @@ -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 ( @@ -52,9 +59,16 @@ export function JourneyList({ {origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'} ) : ( - journeys.map((j) => { + 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 ( - + {j.trains.length > 0 ? j.trains.join(', ') : '—'} + {index === 0 && ( + Top + )} {j.delay > 0 && ( +{j.delay} min )} @@ -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.`}) + + Dauer: {durationMinutes} min + {walkDurationMs > 0 && ( Ziel: {finalArrival.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })} @@ -92,7 +112,13 @@ export function JourneyList({ )} ); - }) + })} + {rankedJourneys.length > 1 && ( + + {expanded ? 'Weniger Verbindungen anzeigen' : `${rankedJourneys.length - 1} weitere Verbindungen anzeigen`} + + )} + )} {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' }, diff --git a/apps/mobile/src/navigation/AppNavigator.tsx b/apps/mobile/src/navigation/AppNavigator.tsx index 6b11270..2f8b5ab 100644 --- a/apps/mobile/src/navigation/AppNavigator.tsx +++ b/apps/mobile/src/navigation/AppNavigator.tsx @@ -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: () => ( - + Time To Leave ), diff --git a/apps/mobile/src/screens/EventListScreen.tsx b/apps/mobile/src/screens/EventListScreen.tsx index ebec576..71f3f20 100644 --- a/apps/mobile/src/screens/EventListScreen.tsx +++ b/apps/mobile/src/screens/EventListScreen.tsx @@ -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 ( - Keine Termine + Keine kommenden Termine navigation.navigate('AddEvent')} @@ -139,7 +145,7 @@ export function EventListScreen({ navigation }: ScreenProps) { item.id} renderItem={renderItem} contentContainerStyle={styles.list} diff --git a/apps/web/src/app/api/hafas/route.ts b/apps/web/src/app/api/hafas/route.ts index 4ac328c..ac2df68 100644 --- a/apps/web/src/app/api/hafas/route.ts +++ b/apps/web/src/app/api/hafas/route.ts @@ -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(); diff --git a/packages/core/src/hafas-parser.ts b/packages/core/src/hafas-parser.ts index ac03761..b6c60b6 100644 --- a/packages/core/src/hafas-parser.ts +++ b/packages/core/src/hafas-parser.ts @@ -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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ea5dae0..0ff46a9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,3 +8,4 @@ export * from './status-utils'; export * from './hafas-time'; export * from './hafas-parser'; export * from './defaults'; +export * from './journey-scoring'; diff --git a/packages/core/src/journey-scoring.ts b/packages/core/src/journey-scoring.ts new file mode 100644 index 0000000..447719e --- /dev/null +++ b/packages/core/src/journey-scoring.ts @@ -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); + }); +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 60447b6..225790b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; }