diff --git a/app/Http/Controllers/API/v1/StatisticsController.php b/app/Http/Controllers/API/v1/StatisticsController.php index 090c0bd6f..d2e6e7f28 100644 --- a/app/Http/Controllers/API/v1/StatisticsController.php +++ b/app/Http/Controllers/API/v1/StatisticsController.php @@ -318,6 +318,18 @@ public function getPersonalStatistics(Request $request): JsonResponse $categories = StatisticBackend::getTopTravelCategoryByUser(user: auth()->user(), from: $from, until: $until); $operators = StatisticBackend::getTopTripOperatorByUser(user: auth()->user(), from: $from, until: $until); $travelTime = StatisticBackend::getDailyTravelTimeByUser(user: auth()->user(), from: $from, until: $until); + + // Advanced statistics + $advancedSummary = StatisticBackend::getAdvancedSummary(user: auth()->user(), from: $from, until: $until); + $distancePerYear = StatisticBackend::getDistancePerYear(user: auth()->user()); + $distancePerMonth = StatisticBackend::getDistancePerMonth(user: auth()->user()); + $distancePerWeek = StatisticBackend::getDistancePerWeek(user: auth()->user()); + $lastWeek = StatisticBackend::getLastWeekStats(user: auth()->user()); + $lastMonth = StatisticBackend::getLastMonthStats(user: auth()->user()); + $lastYear = StatisticBackend::getLastYearStats(user: auth()->user()); + $favoriteStations = StatisticBackend::getFavoriteStations(user: auth()->user(), from: $from, until: $until); + $favoriteLines = StatisticBackend::getFavoriteLines(user: auth()->user(), from: $from, until: $until); + $favoriteRoutes = StatisticBackend::getFavoriteRoutes(user: auth()->user(), from: $from, until: $until); $returnData = [ 'purpose' => $purposes, @@ -330,6 +342,22 @@ public function getPersonalStatistics(Request $request): JsonResponse 'duration' => $row->duration, ]; }), + 'summary' => $advancedSummary, + 'by_period' => [ + 'yearly' => $distancePerYear, + 'monthly' => $distancePerMonth, + 'weekly' => $distancePerWeek, + ], + 'predefined_periods' => [ + 'last_week' => $lastWeek, + 'last_month' => $lastMonth, + 'last_year' => $lastYear, + ], + 'favorites' => [ + 'stations' => $favoriteStations, + 'lines' => $favoriteLines, + 'routes' => $favoriteRoutes, + ], ]; $additionalData = [ diff --git a/app/Http/Controllers/Backend/StatisticController.php b/app/Http/Controllers/Backend/StatisticController.php index 8cb7d6722..16325f664 100644 --- a/app/Http/Controllers/Backend/StatisticController.php +++ b/app/Http/Controllers/Backend/StatisticController.php @@ -222,4 +222,416 @@ public static function getTravelPurposes(User $user, Carbon $from, Carbon $until return $row; }); } + + /** + * Get distance and count summary for a user within a date range + * @api v1 + */ + public static function getAdvancedSummary( + User $user, + Carbon $from, + Carbon $until + ): array { + $from->startOfDay(); + $until->endOfDay(); + + if ($from->isAfter($until)) { + throw new InvalidArgumentException('since cannot be after until'); + } + + $summary = DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.departure', '>=', $from->toIso8601String()) + ->where('train_checkins.departure', '<=', $until->toIso8601String()) + ->where('train_checkins.distance', '>', 0) + ->select([ + DB::raw('COUNT(*) AS total_checkins'), + DB::raw('COUNT(DISTINCT DATE(train_checkins.departure)) AS active_days'), + DB::raw('SUM(train_checkins.distance) AS total_distance_meters'), + DB::raw('AVG(train_checkins.distance) AS mean_distance_meters'), + DB::raw('MAX(train_checkins.distance) AS max_distance'), + DB::raw('MIN(train_checkins.distance) AS min_distance'), + ]) + ->first(); + + $longest = DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->join('hafas_trips', 'train_checkins.trip_id', '=', 'hafas_trips.trip_id') + ->leftJoin('operators', 'operators.id', '=', 'hafas_trips.operator_id') + ->leftJoin('train_stations as origin_station', 'origin_station.id', '=', 'hafas_trips.origin_id') + ->leftJoin('train_stations as destination_station', 'destination_station.id', '=', 'hafas_trips.destination_id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.departure', '>=', $from->toIso8601String()) + ->where('train_checkins.departure', '<=', $until->toIso8601String()) + ->where('train_checkins.distance', '>', 0) + ->orderByDesc('train_checkins.distance') + ->select([ + 'train_checkins.id', + 'train_checkins.distance', + 'train_checkins.departure', + 'hafas_trips.departure as start', + 'hafas_trips.arrival as end', + 'hafas_trips.linename', + 'hafas_trips.number', + 'operators.name as operator_name', + 'origin_station.name as origin_name', + 'destination_station.name as destination_name', + ]) + ->first(); + + $shortest = DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->join('hafas_trips', 'train_checkins.trip_id', '=', 'hafas_trips.trip_id') + ->leftJoin('operators', 'operators.id', '=', 'hafas_trips.operator_id') + ->leftJoin('train_stations as origin_station', 'origin_station.id', '=', 'hafas_trips.origin_id') + ->leftJoin('train_stations as destination_station', 'destination_station.id', '=', 'hafas_trips.destination_id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.departure', '>=', $from->toIso8601String()) + ->where('train_checkins.departure', '<=', $until->toIso8601String()) + ->where('train_checkins.distance', '>', 0) + ->orderBy('train_checkins.distance') + ->select([ + 'train_checkins.id', + 'train_checkins.distance', + 'train_checkins.departure', + 'hafas_trips.departure as start', + 'hafas_trips.arrival as end', + 'hafas_trips.linename', + 'hafas_trips.number', + 'operators.name as operator_name', + 'origin_station.name as origin_name', + 'destination_station.name as destination_name', + ]) + ->first(); + + return [ + 'total_checkins' => (int) ($summary->total_checkins ?? 0), + 'active_days' => (int) ($summary->active_days ?? 0), + 'total_distance_km' => round(($summary->total_distance_meters ?? 0) / 1000, 2), + 'mean_distance_km' => round(($summary->mean_distance_meters ?? 0) / 1000, 2), + 'longest_ride' => $longest ? [ + 'id' => $longest->id, + 'distance_km' => round($longest->distance / 1000, 2), + 'departure' => $longest->departure, + 'start' => $longest->start, + 'end' => $longest->end, + 'linename' => $longest->linename, + 'number' => $longest->number, + 'operator' => $longest->operator_name, + 'origin' => $longest->origin_name, + 'destination' => $longest->destination_name, + ] : null, + 'shortest_ride' => $shortest ? [ + 'id' => $shortest->id, + 'distance_km' => round($shortest->distance / 1000, 2), + 'departure' => $shortest->departure, + 'start' => $shortest->start, + 'end' => $shortest->end, + 'linename' => $shortest->linename, + 'number' => $shortest->number, + 'operator' => $shortest->operator_name, + 'origin' => $shortest->origin_name, + 'destination' => $shortest->destination_name, + ] : null, + ]; + } + + /** + * Get distance and checkin counts aggregated by year + * @api v1 + */ + public static function getDistancePerYear(User $user): Collection + { + $driver = DB::getDriverName(); + $periodExpression = $driver === 'sqlite' + ? 'strftime("%Y", train_checkins.departure)' + : 'YEAR(train_checkins.departure)'; + $periodSelect = DB::raw($periodExpression . ' AS year'); + + return DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.distance', '>', 0) + ->groupBy(DB::raw($periodExpression)) + ->select([ + $periodSelect, + DB::raw('COUNT(*) AS checkin_count'), + DB::raw('SUM(train_checkins.distance) AS total_distance_meters'), + ]) + ->orderBy('year') + ->get() + ->map(function ($row) { + return [ + 'period' => (string) $row->year, + 'period_type' => 'year', + 'checkin_count' => (int) $row->checkin_count, + 'distance_km' => round($row->total_distance_meters / 1000, 2), + ]; + }); + } + + /** + * Get distance and checkin counts aggregated by month + * @api v1 + */ + public static function getDistancePerMonth(User $user, ?int $year = null): Collection + { + $query = DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.distance', '>', 0); + + if ($year !== null) { + $query->whereYear('train_checkins.departure', $year); + } + + $driver = DB::getDriverName(); + $periodExpression = $driver === 'sqlite' + ? 'strftime("%Y-%m", train_checkins.departure)' + : 'DATE_FORMAT(train_checkins.departure, "%Y-%m")'; + $periodSelect = DB::raw($periodExpression . ' AS period'); + + return $query + ->groupBy(DB::raw($periodExpression)) + ->select([ + $periodSelect, + DB::raw('COUNT(*) AS checkin_count'), + DB::raw('SUM(train_checkins.distance) AS total_distance_meters'), + ]) + ->orderBy('period') + ->get() + ->map(function ($row) { + return [ + 'period' => $row->period, + 'period_type' => 'month', + 'checkin_count' => (int) $row->checkin_count, + 'distance_km' => round($row->total_distance_meters / 1000, 2), + ]; + }); + } + + /** + * Get distance and checkin counts aggregated by week + * @api v1 + */ + public static function getDistancePerWeek(User $user, ?int $year = null, ?int $month = null): Collection + { + $query = DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.distance', '>', 0); + + if ($year !== null) { + $query->whereYear('train_checkins.departure', $year); + } + + if ($month !== null) { + $query->whereMonth('train_checkins.departure', $month); + } + + $driver = DB::getDriverName(); + $periodExpression = $driver === 'sqlite' + ? 'strftime("%Y-W%W", train_checkins.departure)' + : 'CONCAT(YEAR(train_checkins.departure), "-W", LPAD(WEEK(train_checkins.departure), 2, "0"))'; + $periodSelect = DB::raw($periodExpression . ' AS period'); + + return $query + ->groupBy(DB::raw($periodExpression)) + ->select([ + $periodSelect, + DB::raw('COUNT(*) AS checkin_count'), + DB::raw('SUM(train_checkins.distance) AS total_distance_meters'), + ]) + ->orderBy('period') + ->get() + ->map(function ($row) { + return [ + 'period' => $row->period, + 'period_type' => 'week', + 'checkin_count' => (int) $row->checkin_count, + 'distance_km' => round($row->total_distance_meters / 1000, 2), + ]; + }); + } + + /** + * Get summary statistics for last N days + * @api v1 + */ + public static function getStatsForLastDays(User $user, int $days): array + { + $until = Carbon::now(); + $from = $until->clone()->subDays($days); + + return self::getAdvancedSummary($user, $from, $until); + } + + /** + * Get summary statistics for last week (7 days) + * @api v1 + */ + public static function getLastWeekStats(User $user): array + { + return self::getStatsForLastDays($user, 7); + } + + /** + * Get summary statistics for last month (30 days) + * @api v1 + */ + public static function getLastMonthStats(User $user): array + { + return self::getStatsForLastDays($user, 30); + } + + /** + * Get summary statistics for last year (365 days) + * @api v1 + */ + public static function getLastYearStats(User $user): array + { + return self::getStatsForLastDays($user, 365); + } + + /** + * Get most visited stations (as origin or destination) for a user. + * @api v1 + */ + public static function getFavoriteStations( + User $user, + Carbon $from, + Carbon $until, + int $limit = 10 + ): Collection { + $from->startOfDay(); + $until->endOfDay(); + + $destinations = DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->join('train_stopovers as sv', 'train_checkins.destination_stopover_id', '=', 'sv.id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.departure', '>=', $from->toIso8601String()) + ->where('train_checkins.departure', '<=', $until->toIso8601String()) + ->whereNotNull('train_checkins.destination_stopover_id') + ->select('sv.train_station_id as station_id'); + + $allStations = DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->join('train_stopovers as sv', 'train_checkins.origin_stopover_id', '=', 'sv.id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.departure', '>=', $from->toIso8601String()) + ->where('train_checkins.departure', '<=', $until->toIso8601String()) + ->whereNotNull('train_checkins.origin_stopover_id') + ->select('sv.train_station_id as station_id') + ->unionAll($destinations); + + return DB::table(DB::raw("({$allStations->toSql()}) as combined")) + ->mergeBindings($allStations) + ->join('train_stations', 'combined.station_id', '=', 'train_stations.id') + ->groupBy('combined.station_id', 'train_stations.name') + ->select([ + 'combined.station_id', + 'train_stations.name', + DB::raw('COUNT(*) AS visit_count'), + ]) + ->orderByDesc('visit_count') + ->limit($limit) + ->get() + ->map(function ($row) { + return [ + 'station_id' => (int) $row->station_id, + 'name' => $row->name, + 'count' => (int) $row->visit_count, + ]; + }); + } + + /** + * Get most used train lines (by linename) for a user. + * @api v1 + */ + public static function getFavoriteLines( + User $user, + Carbon $from, + Carbon $until, + int $limit = 10 + ): Collection { + $from->startOfDay(); + $until->endOfDay(); + + return DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->join('hafas_trips', 'train_checkins.trip_id', '=', 'hafas_trips.trip_id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.departure', '>=', $from->toIso8601String()) + ->where('train_checkins.departure', '<=', $until->toIso8601String()) + ->whereNotNull('hafas_trips.linename') + ->groupBy('hafas_trips.linename', 'hafas_trips.number') + ->select([ + 'hafas_trips.linename', + 'hafas_trips.number', + DB::raw('COUNT(*) AS count'), + DB::raw('SUM(train_checkins.distance) AS total_distance_meters'), + ]) + ->orderByDesc('count') + ->limit($limit) + ->get() + ->map(function ($row) { + return [ + 'linename' => $row->linename, + 'number' => $row->number, + 'count' => (int) $row->count, + 'distance_km' => round(($row->total_distance_meters ?? 0) / 1000, 2), + ]; + }); + } + + /** + * Get most used origin→destination station pairs for a user. + * @api v1 + */ + public static function getFavoriteRoutes( + User $user, + Carbon $from, + Carbon $until, + int $limit = 10 + ): Collection { + $from->startOfDay(); + $until->endOfDay(); + + return DB::table('train_checkins') + ->join('statuses', 'train_checkins.status_id', '=', 'statuses.id') + ->join('train_stopovers as orig_sv', 'train_checkins.origin_stopover_id', '=', 'orig_sv.id') + ->join('train_stations as orig_st', 'orig_sv.train_station_id', '=', 'orig_st.id') + ->join('train_stopovers as dest_sv', 'train_checkins.destination_stopover_id', '=', 'dest_sv.id') + ->join('train_stations as dest_st', 'dest_sv.train_station_id', '=', 'dest_st.id') + ->where('statuses.user_id', '=', $user->id) + ->where('train_checkins.departure', '>=', $from->toIso8601String()) + ->where('train_checkins.departure', '<=', $until->toIso8601String()) + ->whereNotNull('train_checkins.origin_stopover_id') + ->whereNotNull('train_checkins.destination_stopover_id') + ->groupBy('orig_sv.train_station_id', 'dest_sv.train_station_id', 'orig_st.name', 'dest_st.name') + ->select([ + 'orig_sv.train_station_id as origin_id', + 'orig_st.name as origin_name', + 'dest_sv.train_station_id as destination_id', + 'dest_st.name as destination_name', + DB::raw('COUNT(*) AS count'), + DB::raw('SUM(train_checkins.distance) AS total_distance_meters'), + ]) + ->orderByDesc('count') + ->limit($limit) + ->get() + ->map(function ($row) { + return [ + 'origin_id' => (int) $row->origin_id, + 'origin' => $row->origin_name, + 'destination_id' => (int) $row->destination_id, + 'destination' => $row->destination_name, + 'count' => (int) $row->count, + 'distance_km' => round(($row->total_distance_meters ?? 0) / 1000, 2), + ]; + }); + } } diff --git a/lang/de.json b/lang/de.json index d6c26524e..f8e846c60 100644 --- a/lang/de.json +++ b/lang/de.json @@ -773,6 +773,27 @@ "station.technical-details": "Technische Daten", "stationboard.where-are-you": "Wo bist Du?", "stats": "Statistiken", + "stats.avg": "Durchschnitt", + "stats.avg-per-ride": "Ø/Fahrt", + "stats.checkins": "Check-ins", + "stats.distance": "Distanz", + "stats.extremes": "Längste & kürzeste Fahrten", + "stats.favorite-lines": "Beliebteste Linien", + "stats.favorite-routes": "Beliebteste Verbindungen", + "stats.favorite-stations": "Beliebteste Bahnhöfe", + "stats.last-month": "Letzter Monat", + "stats.last-week": "Letzte Woche", + "stats.last-year": "Letztes Jahr", + "stats.longest-ride": "Längste Fahrt", + "stats.mean-distance": "Durchschnittsdistanz", + "stats.month": "Monat", + "stats.monthly-breakdown": "Monatsübersicht", + "stats.shortest-ride": "Kürzeste Fahrt", + "stats.travel-days": "Reisetage", + "stats.time-comparison": "Rückblick", + "stats.total-distance": "Gesamtdistanz", + "stats.year": "Jahr", + "stats.yearly-breakdown": "Jahresübersicht", "stats-day": "Deine Fahrten am :date", "stats.categories": "Verkehrsmittel deiner Reisen", "stats.companies": "Verkehrsunternehmen", diff --git a/lang/en.json b/lang/en.json index 3dfb14bd4..6d64e1951 100644 --- a/lang/en.json +++ b/lang/en.json @@ -773,6 +773,27 @@ "station.technical-details": "Technical details", "stationboard.where-are-you": "Where are you?", "stats": "Statistics", + "stats.avg": "Average", + "stats.avg-per-ride": "Avg/Ride", + "stats.checkins": "Check-ins", + "stats.distance": "Distance", + "stats.extremes": "Extremes", + "stats.favorite-lines": "Favourite Lines", + "stats.favorite-routes": "Favourite Routes", + "stats.favorite-stations": "Favourite Stations", + "stats.last-month": "Last Month", + "stats.last-week": "Last Week", + "stats.last-year": "Last Year", + "stats.longest-ride": "Longest Ride", + "stats.mean-distance": "Mean Distance", + "stats.month": "Month", + "stats.monthly-breakdown": "Monthly Breakdown", + "stats.shortest-ride": "Shortest Ride", + "stats.travel-days": "Travel Days", + "stats.time-comparison": "Time Comparison", + "stats.total-distance": "Total Distance", + "stats.year": "Year", + "stats.yearly-breakdown": "Yearly Breakdown", "stats-day": "Your journeys at :date", "stats.categories": "Means of transport of your travels", "stats.companies": "Transport companies", diff --git a/resources/tailwind-app/components/Stats/AdvancedStats.vue b/resources/tailwind-app/components/Stats/AdvancedStats.vue new file mode 100644 index 000000000..011e8fec4 --- /dev/null +++ b/resources/tailwind-app/components/Stats/AdvancedStats.vue @@ -0,0 +1,315 @@ + + + diff --git a/resources/tailwind-app/pages/Statistics/Statistics.vue b/resources/tailwind-app/pages/Statistics/Statistics.vue index 4f08d23f1..80917bed1 100644 --- a/resources/tailwind-app/pages/Statistics/Statistics.vue +++ b/resources/tailwind-app/pages/Statistics/Statistics.vue @@ -4,6 +4,7 @@ import { ChartNoAxesCombined } from 'lucide-vue-next'; import { Notyf } from 'notyf'; import { computed, inject, onMounted, ref, watch } from 'vue'; import { Api, type StatisticsGlobalData } from '../../../types/Api.gen'; +import AdvancedStats from '../../components/Stats/AdvancedStats.vue'; import ChartDoughnut from '../../components/Stats/ChartDoughnut.vue'; import ChartHorizontalBar from '../../components/Stats/ChartHorizontalBar.vue'; import ChartTimeline from '../../components/Stats/ChartTimeline.vue'; @@ -18,10 +19,101 @@ type StatsData = { categories: { name: string; duration: number; count: number }[]; operators: { name: string; duration: number; count: number }[]; time: { date: string; duration: number; count: number }[]; + summary: AdvancedSummary | null; + by_period: { + yearly: PeriodData[]; + monthly: PeriodData[]; + weekly: PeriodData[]; + }; + predefined_periods: { + last_week: AdvancedSummary | null; + last_month: AdvancedSummary | null; + last_year: AdvancedSummary | null; + } | null; + favorites: { + stations: { station_id: number; name: string; count: number }[]; + lines: { linename: string; number: string | null; count: number; distance_km: number }[]; + routes: { + origin_id: number; + origin: string; + destination_id: number; + destination: string; + count: number; + distance_km: number; + }[]; + } | null; +}; + +type RideSummary = { + id: number; + distance_km: number; + departure: string; + start: string; + end: string; + linename: string | null; + number: string | null; + operator: string | null; + origin: string | null; + destination: string | null; +}; + +type AdvancedSummary = { + total_checkins: number; + active_days: number; + total_distance_km: number; + mean_distance_km: number; + longest_ride: RideSummary | null; + shortest_ride: RideSummary | null; +}; + +type PeriodData = { + period: string; + period_type: string; + checkin_count: number; + distance_km: number; +}; + +type AdvancedStatsApiData = { + purpose?: { name?: string | number; duration?: number; count?: number }[]; + categories?: { name?: string; duration?: number; count?: number }[]; + operators?: { name?: string; duration?: number; count?: number }[]; + time?: { date?: string; duration?: number; count?: number }[]; + summary?: AdvancedSummary | null; + by_period?: { + yearly?: PeriodData[]; + monthly?: PeriodData[]; + weekly?: PeriodData[]; + }; + predefined_periods?: { + last_week?: AdvancedSummary | null; + last_month?: AdvancedSummary | null; + last_year?: AdvancedSummary | null; + } | null; + favorites?: { + stations?: { station_id: number; name: string; count: number }[]; + lines?: { linename: string; number: string | null; count: number; distance_km: number }[]; + routes?: { + origin_id: number; + origin: string; + destination_id: number; + destination: string; + count: number; + distance_km: number; + }[]; + } | null; }; const loading = ref(true); -const data = ref({ purpose: [], categories: [], operators: [], time: [] }); +const data = ref({ + purpose: [], + categories: [], + operators: [], + time: [], + summary: null, + by_period: { yearly: [], monthly: [], weekly: [] }, + predefined_periods: null, + favorites: null, +}); const globalStats = ref(null); const globalFrom = ref(null); const globalUntil = ref(null); @@ -84,7 +176,7 @@ async function fetchStats(): Promise { loading.value = true; try { const res = await api.statistics.getStatistics({ from: fromStr.value, until: untilStr.value }); - const d = res.data.data; + const d = res.data.data as AdvancedStatsApiData | undefined; data.value = { purpose: (d?.purpose ?? []).map((p) => ({ name: String(p.name ?? ''), @@ -106,6 +198,26 @@ async function fetchStats(): Promise { duration: t.duration ?? 0, count: t.count ?? 0, })), + summary: d?.summary ?? null, + by_period: { + yearly: d?.by_period?.yearly ?? [], + monthly: d?.by_period?.monthly ?? [], + weekly: d?.by_period?.weekly ?? [], + }, + predefined_periods: d?.predefined_periods + ? { + last_week: d.predefined_periods.last_week ?? null, + last_month: d.predefined_periods.last_month ?? null, + last_year: d.predefined_periods.last_year ?? null, + } + : null, + favorites: d?.favorites + ? { + stations: d.favorites.stations ?? [], + lines: d.favorites.lines ?? [], + routes: d.favorites.routes ?? [], + } + : null, }; } catch (e: unknown) { notyf.error(e instanceof Error ? e.message : trans('generic.error')); @@ -175,6 +287,9 @@ onMounted(() => { {{ trans('stats') }} +

+ {{ trans('stats.personal', { fromDate: from.toLocaleDateString(), toDate: until.toLocaleDateString() }) }} +

@@ -229,6 +344,8 @@ onMounted(() => {