diff options
Diffstat (limited to 'lib/Travelynx/Model')
-rwxr-xr-x | lib/Travelynx/Model/JourneyStatsCache.pm | 100 | ||||
-rwxr-xr-x | lib/Travelynx/Model/Journeys.pm | 205 |
2 files changed, 269 insertions, 36 deletions
diff --git a/lib/Travelynx/Model/JourneyStatsCache.pm b/lib/Travelynx/Model/JourneyStatsCache.pm new file mode 100755 index 0000000..145208d --- /dev/null +++ b/lib/Travelynx/Model/JourneyStatsCache.pm @@ -0,0 +1,100 @@ +package Travelynx::Model::JourneyStatsCache; +# Copyright (C) 2020 Daniel Friesel +# +# SPDX-License-Identifier: MIT + +use strict; +use warnings; +use 5.020; +use utf8; + +import JSON; + +sub new { + my ( $class, %opt ) = @_; + + return bless( \%opt, $class ); +} + +sub add { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + + eval { + $db->insert( + 'journey_stats', + { + user_id => $opt{uid}, + year => $opt{year}, + month => $opt{month}, + data => JSON->new->encode($opt{stats}), + } + ); + }; + if ( my $err = $@ ) { + if ( $err =~ m{duplicate key value violates unique constraint} ) + { + # If a user opens the same history page several times in + # short succession, there is a race condition where several + # Mojolicious workers execute this helper, notice that there is + # no up-to-date history, compute it, and insert it using the + # statement above. This will lead to a uniqueness violation + # in each successive insert. However, this is harmless, and + # thus ignored. + } + else { + # Otherwise we probably have a problem. + die($@); + } + } +} + +sub get { + my ( $self, %opt ) = @_; + + my $db = $opt{db} // $self->{pg}->db; + + my $stats = $db->select( + 'journey_stats', + ['data'], + { + user_id => $opt{uid}, + year => $opt{year}, + month => $opt{month} + } + )->expand->hash; + + return $stats->{data}; +} + +# Statistics are partitioned by real_departure, which must be provided +# when calling this function e.g. after journey deletion or editing. +# If a joureny's real_departure has been edited, this function must be +# called twice: once with the old and once with the new value. +sub invalidate { + my ( $self, %opt ) = @_; + + my $ts = $opt{ts}; + my $db = $opt{db} // $self->{pg}->db; + my $uid = $opt{uid}; + + $db->delete( + 'journey_stats', + { + user_id => $uid, + year => $ts->year, + month => $ts->month, + } + ); + $db->delete( + 'journey_stats', + { + user_id => $uid, + year => $ts->year, + month => 0, + } + ); +} + +1; diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm index 3a6afd5..f5f5424 100755 --- a/lib/Travelynx/Model/Journeys.pm +++ b/lib/Travelynx/Model/Journeys.pm @@ -1,4 +1,5 @@ package Travelynx::Model::Journeys; + # Copyright (C) 2020 Daniel Friesel # # SPDX-License-Identifier: MIT @@ -85,6 +86,11 @@ sub new { return bless( \%opt, $class ); } +sub stats_cache { + my ($self) = @_; + return $self->{stats_cache}; +} + # Returns (journey id, error) # Must be called during a transaction. # Must perform a rollback on error. @@ -191,7 +197,7 @@ sub add { $journey_id = $db->insert( 'journeys', $entry, { returning => 'id' } ) ->hash->{id}; - $self->invalidate_stats_cache( + $self->stats_cache->invalidate( ts => $opt{rt_departure}, db => $db, uid => $uid @@ -294,7 +300,7 @@ sub update { # stats are partitioned by rt_departure -> both the cache for # the old value (see bottom of this function) and the new value # (here) must be invalidated. - $self->invalidate_stats_cache( + $self->stats_cache->invalidate( ts => $opt{rt_departure}, db => $db, uid => $uid, @@ -371,7 +377,7 @@ sub update { return "update($journey_id): $@"; } if ( $rows == 1 ) { - $self->invalidate_stats_cache( + $self->stats_cache->invalidate( ts => $journey->{rt_departure}, db => $db, uid => $uid, @@ -426,7 +432,7 @@ sub delete { } if ( $rows == 1 ) { - $self->invalidate_stats_cache( + $self->stats_cache->invalidate( ts => epoch_to_dt( $journey->{rt_dep_ts} ), uid => $uid ); @@ -743,18 +749,15 @@ sub get_months_for_year { if ( $row->{year} == $year ) { # TODO delegate query to the (not yet present) JourneyStats model - my $stats = $db->select( - 'journey_stats', - ['data'], - { - user_id => $uid, - year => $year, - month => $row->{month} - } - )->expand->hash; + my $stats = $self->stats_cache->get( + db => $db, + uid => $uid, + year => $year, + month => $row->{month} + ); # undef -> no journeys for this month; empty hash -> no cached stats - $ret[ $row->{month} - 1 ][2] = $stats->{data} // {}; + $ret[ $row->{month} - 1 ][2] = $stats // {}; } } return @ret; @@ -943,33 +946,163 @@ sub get_travel_distance { $distance_beeline, $skipped ); } -# Statistics are partitioned by real_departure, which must be provided -# when calling this function e.g. after journey deletion or editing. -# If a joureny's real_departure has been edited, this function must be -# called twice: once with the old and once with the new value. -sub invalidate_stats_cache { - my ( $self, %opt ) = @_; - - my $ts = $opt{ts}; - my $db = $opt{db} // $self->{pg}->db; - my $uid = $opt{uid}; - - $db->delete( - 'journey_stats', +sub compute_stats { + my ( $self, @journeys ) = @_; + my $km_route = 0; + my $km_beeline = 0; + my $min_travel_sched = 0; + my $min_travel_real = 0; + my $delay_dep = 0; + my $delay_arr = 0; + my $interchange_real = 0; + my $num_trains = 0; + my $num_journeys = 0; + my @inconsistencies; + + my $next_departure = 0; + + for my $journey (@journeys) { + $num_trains++; + $km_route += $journey->{km_route}; + $km_beeline += $journey->{km_beeline}; + if ( $journey->{sched_duration} + and $journey->{sched_duration} > 0 ) { - user_id => $uid, - year => $ts->year, - month => $ts->month, + $min_travel_sched += $journey->{sched_duration} / 60; } - ); - $db->delete( - 'journey_stats', + if ( $journey->{rt_duration} and $journey->{rt_duration} > 0 ) { + $min_travel_real += $journey->{rt_duration} / 60; + } + if ( $journey->{sched_dep_ts} and $journey->{rt_dep_ts} ) { + $delay_dep + += ( $journey->{rt_dep_ts} - $journey->{sched_dep_ts} ) / 60; + } + if ( $journey->{sched_arr_ts} and $journey->{rt_arr_ts} ) { + $delay_arr + += ( $journey->{rt_arr_ts} - $journey->{sched_arr_ts} ) / 60; + } + + # Note that journeys are sorted from recent to older entries + if ( $journey->{rt_arr_ts} + and $next_departure + and $next_departure - $journey->{rt_arr_ts} < ( 60 * 60 ) ) { - user_id => $uid, - year => $ts->year, - month => 0, + if ( $next_departure - $journey->{rt_arr_ts} < 0 ) { + push( @inconsistencies, + epoch_to_dt($next_departure)->strftime('%d.%m.%Y %H:%M') ); + } + else { + $interchange_real + += ( $next_departure - $journey->{rt_arr_ts} ) / 60; + } } + else { + $num_journeys++; + } + $next_departure = $journey->{rt_dep_ts}; + } + my $ret = { + km_route => $km_route, + km_beeline => $km_beeline, + num_trains => $num_trains, + num_journeys => $num_journeys, + min_travel_sched => $min_travel_sched, + min_travel_real => $min_travel_real, + min_interchange_real => $interchange_real, + delay_dep => $delay_dep, + delay_arr => $delay_arr, + inconsistencies => \@inconsistencies, + }; + for my $key ( + qw(min_travel_sched min_travel_real min_interchange_real delay_dep delay_arr) + ) + { + my $strf_key = $key . '_strf'; + my $value = $ret->{$key}; + $ret->{$strf_key} = q{}; + if ( $ret->{$key} < 0 ) { + $ret->{$strf_key} .= '-'; + $value *= -1; + } + $ret->{$strf_key} .= sprintf( '%02d:%02d', $value / 60, $value % 60 ); + } + return $ret; +} + +sub get_stats { + my ( $self, %opt ) = @_; + + if ( $opt{cancelled} ) { + $self->{log} + ->warn('get_journey_stats called with illegal option cancelled => 1'); + return {}; + } + + my $uid = $opt{uid}; + my $db = $opt{db} // $self->{pg}->db; + my $year = $opt{year} // 0; + my $month = $opt{month} // 0; + + # Assumption: If the stats cache contains an entry it is up-to-date. + # -> Cache entries must be explicitly invalidated whenever the user + # checks out of a train or manually edits/adds a journey. + + if ( + my $stats = $self->stats_cache->get( + uid => $uid, + db => $db, + year => $year, + month => $month + ) + ) + { + return $stats; + } + + my $interval_start = DateTime->new( + time_zone => 'Europe/Berlin', + year => 2000, + month => 1, + day => 1, + hour => 0, + minute => 0, + second => 0, + ); + + # I wonder if people will still be traveling by train in the year 3000 + my $interval_end = $interval_start->clone->add( years => 1000 ); + + if ( $opt{year} and $opt{month} ) { + $interval_start->set( + year => $opt{year}, + month => $opt{month} + ); + $interval_end = $interval_start->clone->add( months => 1 ); + } + elsif ( $opt{year} ) { + $interval_start->set( year => $opt{year} ); + $interval_end = $interval_start->clone->add( years => 1 ); + } + + my @journeys = $self->get( + uid => $uid, + cancelled => $opt{cancelled} ? 1 : 0, + verbose => 1, + with_polyline => 1, + after => $interval_start, + before => $interval_end + ); + my $stats = $self->compute_stats(@journeys); + + $self->stats_cache->add( + uid => $uid, + db => $db, + year => $year, + month => $month, + stats => $stats ); + + return $stats; } 1; |