summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cpanfile5
-rwxr-xr-xlib/Travelynx.pm8
-rw-r--r--lib/Travelynx/Controller/Account.pm7
-rwxr-xr-xlib/Travelynx/Controller/Traveling.pm212
-rwxr-xr-xlib/Travelynx/Model/Journeys.pm102
-rw-r--r--share/locales/de_DE.po24
-rw-r--r--share/locales/en_GB.po24
-rw-r--r--templates/journey.html.ep112
-rw-r--r--templates/polyline.gpx.ep20
-rw-r--r--templates/select_backend.html.ep14
10 files changed, 483 insertions, 45 deletions
diff --git a/cpanfile b/cpanfile
index 6fccfd1..221e919 100644
--- a/cpanfile
+++ b/cpanfile
@@ -8,6 +8,8 @@ requires 'GIS::Distance';
requires 'GIS::Distance::Fast';
requires 'IO::Socket::Socks', '>= 0.64';
requires 'IO::Socket::SSL', '>= 2.009';
+requires 'JSON';
+requires 'JSON::XS';
requires 'List::UtilsBy';
requires 'Locale::Maketext';
requires 'Locale::Maketext::Lexicon';
@@ -25,5 +27,4 @@ requires 'Travel::Status::DE::DBRIS', '>= 0.10';
requires 'Travel::Status::DE::HAFAS', '>= 6.20';
requires 'Travel::Status::DE::IRIS';
requires 'UUID::Tiny';
-requires 'JSON';
-requires 'JSON::XS';
+requires 'XML::LibXML';
diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm
index 607b153..d8e1cf4 100755
--- a/lib/Travelynx.pm
+++ b/lib/Travelynx.pm
@@ -69,6 +69,8 @@ sub startup {
$self->types->type( csv => 'text/csv; charset=utf-8' );
$self->types->type( json => 'application/json; charset=utf-8' );
+ $self->types->type( gpx => 'application/gpx+xml; charset=utf-8' );
+ $self->types->type( xml => 'text/xml; charset=utf-8' );
$self->plugin('Config');
@@ -3174,6 +3176,11 @@ sub startup {
$authed_r->get( '/journey/:id' => [ format => [ 'html', 'json' ] ] )
->to( 'traveling#journey_details', format => undef )
->name('journey');
+ $authed_r->get( '/polyline/:id' => [ format => [ 'gpx', 'json' ] ] )->to(
+ 'traveling#journey_details',
+ format => undef,
+ polyline_export => 1
+ )->name('polyline_download');
$authed_r->get('/s/*station')->to('traveling#station');
$authed_r->get('/confirm_mail/:token')->to('account#confirm_mail');
$authed_r->post('/account/privacy')->to('account#privacy');
@@ -3186,6 +3193,7 @@ sub startup {
$authed_r->post('/account/select_backend')->to('account#change_backend');
$authed_r->post('/checkin/add')->to('traveling#add_intransit_form');
$authed_r->post('/journey/add')->to('traveling#add_journey_form');
+ $authed_r->post('/polyline/set')->to('traveling#set_polyline');
$authed_r->post('/journey/comment')->to('traveling#comment_form');
$authed_r->post('/journey/visibility')->to('traveling#visibility_form');
$authed_r->post('/journey/edit')->to('traveling#edit_journey');
diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm
index 3a1e281..96be200 100644
--- a/lib/Travelynx/Controller/Account.pm
+++ b/lib/Travelynx/Controller/Account.pm
@@ -1055,6 +1055,7 @@ sub backend_form {
my ($self) = @_;
my $user = $self->current_user;
+ my %backend_by_id;
my @backends = $self->stations->get_backends;
my @suggested_backends;
@@ -1272,8 +1273,13 @@ sub backend_form {
}
}
$backend->{type} = $type;
+
+ $backend_by_id{ $backend->{id} } = $backend;
}
+ my @frequent_backends = map { $backend_by_id{$_} }
+ $self->journeys->get_frequent_backend_ids( uid => $user->{id} );
+
@backends = map { $_->[1] }
sort { $a->[0] cmp $b->[0] }
map { [ lc( $_->{name} ), $_ ] } grep { $_->{type} } @backends;
@@ -1281,6 +1287,7 @@ sub backend_form {
$self->render(
'select_backend',
suggestions => \@suggested_backends,
+ frequent => \@frequent_backends,
backends => \@backends,
user => $user,
redirect_to => $self->req->param('redirect_to') // '/',
diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm
index e23301e..bde2902 100755
--- a/lib/Travelynx/Controller/Traveling.pm
+++ b/lib/Travelynx/Controller/Traveling.pm
@@ -8,13 +8,15 @@ use Mojo::Base 'Mojolicious::Controller';
use DateTime;
use DateTime::Format::Strptime;
+use GIS::Distance;
use List::Util qw(uniq min max);
use List::UtilsBy qw(max_by uniq_by);
-use List::MoreUtils qw(first_index);
+use List::MoreUtils qw(first_index last_index);
use Mojo::UserAgent;
use Mojo::Promise;
use Text::CSV;
use Travel::Status::DE::IRIS::Stations;
+use XML::LibXML;
# Internal Helpers
@@ -2149,6 +2151,32 @@ sub journey_details {
);
if ($journey) {
+
+ if ( $self->stash('polyline_export') ) {
+ delete $self->stash->{layout};
+ my $xml = $self->render_to_string(
+ template => 'polyline',
+ name => sprintf( '%s %s: %s → %s',
+ $journey->{type}, $journey->{no},
+ $journey->{from_name}, $journey->{to_name} ),
+ polyline => $journey->{polyline}
+ );
+ $self->respond_to(
+ gpx => {
+ text => $xml,
+ format => 'gpx'
+ },
+ json => {
+ json => [
+ map {
+ $_->[2] ? [ $_->[0], $_->[1], int( $_->[2] ) ] : $_
+ } @{ $journey->{polyline} }
+ ]
+ },
+ );
+ return;
+ }
+
my $map_data = $self->journeys_to_map_data(
journeys => [$journey],
include_manual => 1,
@@ -2539,6 +2567,188 @@ sub edit_journey {
);
}
+sub polyline_add_stops {
+ my ( $self, %opt ) = @_;
+
+ my $polyline = $opt{polyline};
+ my $route = $opt{route};
+
+ my $distance = GIS::Distance->new;
+
+ my %min_dist;
+ for my $stop ( @{$route} ) {
+ for my $polyline_index ( 0 .. $#{$polyline} ) {
+ my $pl = $polyline->[$polyline_index];
+ my $dist
+ = $distance->distance_metal( $stop->[2]{lat}, $stop->[2]{lon},
+ $pl->[1], $pl->[0] );
+ if ( not $min_dist{ $stop->[1] }
+ or $min_dist{ $stop->[1] }{dist} > $dist )
+ {
+ $min_dist{ $stop->[1] } = {
+ dist => $dist,
+ index => $polyline_index,
+ };
+ }
+ }
+ }
+ for my $stop ( @{$route} ) {
+ if ( $min_dist{ $stop->[1] } ) {
+ if ( defined $polyline->[ $min_dist{ $stop->[1] }{index} ][2] ) {
+ return sprintf(
+ 'Error: Stop IDs %d and %d both map to lon %f, lat %f',
+ $polyline->[ $min_dist{ $stop->[1] }{index} ][2],
+ $stop->[1],
+ $polyline->[ $min_dist{ $stop->[1] }{index} ][0],
+ $polyline->[ $min_dist{ $stop->[1] }{index} ][1]
+ );
+ }
+ $polyline->[ $min_dist{ $stop->[1] }{index} ][2]
+ = $stop->[1];
+ }
+ }
+ return;
+}
+
+sub set_polyline {
+ my ($self) = @_;
+
+ if ( $self->validation->csrf_protect->has_error('csrf_token') ) {
+ $self->render(
+ 'bad_request',
+ csrf => 1,
+ status => 400
+ );
+ return;
+ }
+
+ my $journey_id = $self->param('id');
+ my $uid = $self->current_user->{id};
+
+ # Ensure that the journey exists and belongs to the user
+ my $journey = $self->journeys->get_single(
+ uid => $uid,
+ journey_id => $journey_id,
+ );
+
+ if ( not $journey ) {
+ $self->render(
+ 'bad_request',
+ message => 'Invalid journey ID',
+ status => 400,
+ );
+ return;
+ }
+
+ if ( my $upload = $self->req->upload('file') ) {
+ my $root;
+ eval {
+ $root = XML::LibXML->load_xml( string => $upload->asset->slurp );
+ };
+
+ if ($@) {
+ $self->render(
+ 'bad_request',
+ message => "Invalid GPX file: Invalid XML: $@",
+ status => 400,
+ );
+ return;
+ }
+
+ my $context = XML::LibXML::XPathContext->new($root);
+ $context->registerNs( 'gpx', 'http://www.topografix.com/GPX/1/1' );
+
+ use Data::Dumper;
+
+ my @polyline;
+ for my $point (
+ $context->findnodes('/gpx:gpx/gpx:trk/gpx:trkseg/gpx:trkpt') )
+ {
+ push(
+ @polyline,
+ [
+ 0.0 + $point->getAttribute('lon'),
+ 0.0 + $point->getAttribute('lat')
+ ]
+ );
+ }
+
+ if ( not @polyline ) {
+ $self->render(
+ 'bad_request',
+ message => 'Invalid GPX file: found no track points',
+ status => 400,
+ );
+ return;
+ }
+
+ my @route = @{ $journey->{route} };
+
+ if ( $self->param('upload-partial') ) {
+ my $route_start = first_index {
+ (
+ (
+ $_->[1] and $_->[1] == $journey->{from_eva}
+ or $_->[0] eq $journey->{from_name}
+ )
+ and (
+ not( defined $_->[2]{sched_dep}
+ or defined $_->[2]{rt_dep} )
+ or ( $_->[2]{sched_dep} // $_->[2]{rt_dep} )
+ == $journey->{sched_dep_ts}
+ )
+ )
+ }
+ @route;
+
+ my $route_end = last_index {
+ (
+ (
+ $_->[1] and $_->[1] == $journey->{to_eva}
+ or $_->[0] eq $journey->{to_name}
+ )
+ and (
+ not( defined $_->[2]{sched_arr}
+ or defined $_->[2]{rt_arr} )
+ or ( $_->[2]{sched_arr} // $_->[2]{rt_arr} )
+ == $journey->{sched_arr_ts}
+ )
+ )
+ }
+ @route;
+
+ if ( $route_start > -1 and $route_end > -1 ) {
+ @route = @route[ $route_start .. $route_end ];
+ }
+ }
+
+ my $err = $self->polyline_add_stops(
+ polyline => \@polyline,
+ route => \@route,
+ );
+
+ if ($err) {
+ $self->render(
+ 'bad_request',
+ message => $err,
+ status => 400,
+ );
+ return;
+ }
+
+ $self->journeys->set_polyline(
+ uid => $uid,
+ journey_id => $journey_id,
+ edited => $journey->{edited},
+ polyline => \@polyline,
+ from_eva => $route[0][1],
+ to_eva => $route[-1][1],
+ );
+ }
+
+ $self->redirect_to("/journey/${journey_id}");
+}
+
sub add_journey_form {
my ($self) = @_;
diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm
index 9efa365..7621a5f 100755
--- a/lib/Travelynx/Model/Journeys.pm
+++ b/lib/Travelynx/Model/Journeys.pm
@@ -569,6 +569,78 @@ sub pop {
return $journey;
}
+sub set_polyline {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $polyline = $opt{polyline};
+
+ my $from_eva = $opt{from_eva};
+ my $to_eva = $opt{to_eva};
+
+ my $polyline_str = JSON->new->encode($polyline);
+
+ my $pl_res = $db->select(
+ 'polylines',
+ ['id'],
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str,
+ },
+ { limit => 1 }
+ );
+
+ my $polyline_id;
+ if ( my $h = $pl_res->hash ) {
+ $polyline_id = $h->{id};
+ }
+ else {
+ $polyline_id = $db->insert(
+ 'polylines',
+ {
+ origin_eva => $from_eva,
+ destination_eva => $to_eva,
+ polyline => $polyline_str
+ },
+ { returning => 'id' }
+ )->hash->{id};
+ }
+ if ($polyline_id) {
+ $self->set_polyline_id(
+ uid => $uid,
+ db => $db,
+ polyline_id => $polyline_id,
+ journey_id => $opt{journey_id},
+ edited => $opt{edited},
+ );
+ }
+
+}
+
+sub set_polyline_id {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $db = $opt{db} // $self->{pg}->db;
+ my $polyline_id = $opt{polyline_id};
+ my $journey_id = $opt{journey_id};
+ my $edited = $opt{edited};
+
+ $db->update(
+ 'journeys',
+ {
+ polyline_id => $polyline_id,
+ edited => $edited | 0x0040
+ },
+ {
+ user_id => $uid,
+ id => $opt{journey_id}
+ }
+ );
+}
+
sub get {
my ( $self, %opt ) = @_;
@@ -1943,6 +2015,36 @@ sub get_latest_dest_ids {
);
}
+sub get_frequent_backend_ids {
+ my ( $self, %opt ) = @_;
+
+ my $uid = $opt{uid};
+ my $threshold = $opt{threshold}
+ // DateTime->now( time_zone => 'Europe/Berlin' )->subtract( months => 4 );
+ my $limit = $opt{limit} // 5;
+ my $db = $opt{db} //= $self->{pg}->db;
+
+ my $res = $db->select(
+ 'journeys',
+ 'count(*) as count, backend_id',
+ {
+ user_id => $uid,
+ real_departure => { '>', $threshold },
+ },
+ {
+ group_by => ['backend_id'],
+ order_by => { -desc => 'count' },
+ limit => $limit,
+ }
+ );
+
+ my @backend_ids = $res->hashes->map( sub { shift->{backend_id} } )->each;
+
+ say join( ' ', @backend_ids );
+
+ return @backend_ids;
+}
+
# Returns a listref of {eva, name} hashrefs for the specified backend.
sub get_connection_targets {
my ( $self, %opt ) = @_;
diff --git a/share/locales/de_DE.po b/share/locales/de_DE.po
index 97e3877..cf9be32 100644
--- a/share/locales/de_DE.po
+++ b/share/locales/de_DE.po
@@ -270,6 +270,30 @@ msgstr "Exportieren"
msgid "journey.edit"
msgstr "Bearbeiten"
+msgid "journey.map-data"
+msgstr "Kartendaten"
+
+msgid "journey.map.download"
+msgstr "Herunterladen"
+
+msgid "journey.map.upload"
+msgstr "Hochladen"
+
+msgid "journey.map.upload-full"
+msgstr "Komplette Route"
+
+msgid "journey.map.upload-partial"
+msgstr "Gefahrenes Segment"
+
+msgid "journey.map.info.download"
+msgstr "JSON-Format: [[lon, lat, ID], ...] in WGS84-Koordinaten. GPX-Dateien sind mit BRouter kompatibel."
+
+msgid "journey.map.info.upload"
+msgstr "GPX-Uploads müssen ein einzelnes track-Element mit einem einzelnen track segment enthalten. Ein BRouter-GPX-Export erfüllt diese Vorgaben. Uploads müssen entweder die komplette Route des Verkehrsmittels oder nur den zu diesem Checkin zugehörigen Abschnitt enthalten. Beim Hochladen bitte die passende Schaltfläche auswählen. IDs von Halten müssen beim Upload nicht angegeben werden. Bitte beachten: Beim Einspielen eigener Kartendaten werden die zuvor gespeicherten unwiderruflich gelöscht."
+
+msgid "journey.danger"
+msgstr "Danger Zone"
+
msgid "journey.delete"
msgstr "Löschen"
diff --git a/share/locales/en_GB.po b/share/locales/en_GB.po
index 6121045..7c6f97b 100644
--- a/share/locales/en_GB.po
+++ b/share/locales/en_GB.po
@@ -270,6 +270,30 @@ msgstr "Export"
msgid "journey.edit"
msgstr "Edit"
+msgid "journey.map-data"
+msgstr "Map Data"
+
+msgid "journey.map.download"
+msgstr "Download"
+
+msgid "journey.map.upload"
+msgstr "Upload"
+
+msgid "journey.map.upload-full"
+msgstr "Upload full route"
+
+msgid "journey.map.upload-partial"
+msgstr "Upload travelled segment"
+
+msgid "journey.map.info.download"
+msgstr "JSON format: [[lon, lat, station ID], ...], with lon/lat in WGS84 coordinates. GPX files are compatible with BRouter."
+
+msgid "journey.map.info.upload"
+msgstr "GPX uploads must contain a single track with a single track segment (such as provided by BRouter's export). They must cover either the full route or just the travelled route segment that belongs to this checkin. Please use the appropriate upload button, otherwise chaos may ensue. There is no need to specify station IDs when uploading tracks. Note that uploads irreversibly replace previously stored map data."
+
+msgid "journey.danger"
+msgstr "Danger Zone"
+
msgid "journey.delete"
msgstr "Delete"
diff --git a/templates/journey.html.ep b/templates/journey.html.ep
index f9c78ec..8a12ed5 100644
--- a/templates/journey.html.ep
+++ b/templates/journey.html.ep
@@ -317,11 +317,24 @@
</div>
</div>
% if (not stash('readonly')) {
- % if (stash('with_share')) {
- <div class="row">
- <div class="col s12 m6 l6">
- </div>
- <div class="col s12 m6 l6 center-align">
+ <div class="row">
+ <div class="col s12 m6 l4 center-align">
+ <a class="btn waves-effect waves-light" href="<%= url_for('journey', id => $journey->{id}, format => 'json' ) %>">
+ <i class="material-icons left" aria-hidden="true">file_download</i>
+ %= L('journey.export')
+ </a>
+ </div>
+ <div class="col s12 m6 l4 center-align">
+ %= form_for '/journey/edit' => (method => 'POST') => begin
+ %= hidden_field 'journey_id' => param('journey_id')
+ <button class="btn waves-effect waves-light" type="submit" name="action" value="edit">
+ <i class="material-icons left" aria-hidden="true">edit</i>
+ %= L('journey.edit')
+ </button>
+ %= end
+ </div>
+ <div class="col s12 m6 l4 center-align">
+ % if (stash('with_share')) {
<a class="btn waves-effect waves-light action-share"
% if (stash('journey_visibility') eq 'public') {
data-url="<%= url_for('public_journey', name => current_user()->{name}, id => $journey->{id} )->to_abs->scheme('https'); %>"
@@ -334,50 +347,65 @@
<i class="material-icons left" aria-hidden="true">share</i>
%= L('journey.share')
</a>
+ % }
+ </div>
+ </div>
+ <h2><%= L('journey.map-data') %>
+ % if ($journey->{edited} & 0x0040) {
+ ∗
+ % }
+ </h2>
+ % if ($journey->{polyline}) {
+ <div class="row">
+ <div class="col s12 m6 l6 center-align">
+ <a class="btn waves-effect waves-light" href="<%= url_for('polyline_download', id => $journey->{id}, format => 'json' ) %>">
+ <i class="material-icons left" aria-label="<%= L('journey.map.download') %>">file_download</i>
+ JSON
+ </a>
+ </div>
+ <div class="col s12 m6 l6 center-align">
+ <a class="btn waves-effect waves-light" href="<%= url_for('polyline_download', id => $journey->{id}, format => 'gpx' ) %>">
+ <i class="material-icons left" aria-label="<%= L('journey.map.download') %>">file_download</i>
+ GPX
+ </a>
</div>
</div>
% }
<div class="row">
- <div class="col s12 m6 l6">
- </div>
- <div class="col s12 m6 l6 center-align">
- <a class="btn waves-effect waves-light" href="<%= url_for('journey', id => $journey->{id}, format => 'json' ) %>">
- <i class="material-icons left" aria-hidden="true">file_download</i>
- %= L('journey.export')
- </a>
- </div>
- </div>
- <div class="row hide-on-small-only">
- <div class="col s12 m6 l6 center-align">
- <a class="waves-effect waves-light red btn action-delete"
- data-id="<%= $journey->{id} %>"
- data-checkin="<%= $journey->{checkin}->epoch %>"
- data-checkout="<%= $journey->{checkout}->epoch %>">
- <i class="material-icons left">delete_forever</i>
- %= L('journey.delete')
- </a>
- </div>
- <div class="col s12 m6 l6 center-align">
- %= form_for '/journey/edit' => (method => 'POST') => begin
- %= hidden_field 'journey_id' => param('journey_id')
- <button class="btn waves-effect waves-light" type="submit" name="action" value="edit">
- <i class="material-icons left" aria-hidden="true">edit</i>
- %= L('journey.edit')
- </button>
- %= end
+ <div class="col s12">
+ % if ($journey->{polyline}) {
+ %= L('journey.map.info.download')
+ % }
+ %= L('journey.map.info.upload')
</div>
</div>
- <div class="row hide-on-med-and-up">
- <div class="col s12 m6 l6 center-align">
- %= form_for '/journey/edit' => (method => 'POST') => begin
- %= hidden_field 'journey_id' => param('journey_id')
- <button class="btn waves-effect waves-light" type="submit" name="action" value="edit">
- <i class="material-icons left" aria-hidden="true">edit</i>
- %= L('journey.edit')
- </button>
- %= end
+ %= form_for '/polyline/set' => (method => 'post', enctype => 'multipart/form-data') => begin
+ %= csrf_field
+ %= hidden_field id => $journey->{id}
+ <div class="row">
+ <div class="col s12">
+ <div class="file-field input-field">
+ <div class="btn">
+ <span><i class="material-icons left" aria-label="<%= L('journey.map.upload') %>">file_upload</i> GPX</span>
+ <input type="file">
+ </div>
+ <div class="file-path-wrapper">
+ <input class="file-path validate" type="text" name="file">
+ </div>
+ %= file_field 'file'
+ </div>
+ </div>
+ <div class="col s12 m6 center-align">
+ %= submit_button L('journey.map.upload-full'), class => 'waves-effect waves-light btn', name => 'upload-full'
+ </div>
+ <div class="col s12 m6 center-align">
+ %= submit_button L('journey.map.upload-partial'), class => 'waves-effect waves-light btn', name => 'upload-partial'
+ </div>
</div>
- <div class="col s12 m6 l6 center-align" style="margin-top: 1em;">
+ %= end
+ <h2><%= L('journey.danger') %></h2>
+ <div class="row">
+ <div class="col s12 m12 l12 center-align" style="margin-top: 1em;">
<a class="waves-effect waves-light red btn action-delete"
data-id="<%= $journey->{id} %>"
data-checkin="<%= $journey->{checkin}->epoch %>"
diff --git a/templates/polyline.gpx.ep b/templates/polyline.gpx.ep
new file mode 100644
index 0000000..a243926
--- /dev/null
+++ b/templates/polyline.gpx.ep
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<gpx
+ xmlns="http://www.topografix.com/GPX/1/1"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"
+ version="1.1"
+ creator="travelynx <%= $version %>">
+ <trk>
+ <name><%= $name %></name>
+ <trkseg>
+ % for my $entry (@{$polyline // []}) {
+ <trkpt lon="<%= $entry->[0] %>" lat="<%= $entry->[1] %>">
+ % if ($entry->[2]) {
+ <name><%= $entry->[2] %></name>
+ % }
+ </trkpt>
+ % }
+ </trkseg>
+ </trk>
+</gpx>
diff --git a/templates/select_backend.html.ep b/templates/select_backend.html.ep
index e3db44d..999a689 100644
--- a/templates/select_backend.html.ep
+++ b/templates/select_backend.html.ep
@@ -11,6 +11,20 @@
% if (stash('redirect_to')) {
%= hidden_field 'redirect_to' => stash('redirect_to')
% }
+ % if (@{stash('frequent') // []}) {
+ <div class="row">
+ <div class="col s12">
+ <h3>Häufig genutzt</h3>
+ <p style="text-align: justify;">
+ Die folgenden Backends wurden innerhalb der letzten vier
+ Monate für Checkins verwendet.
+ </p>
+ </div>
+ </div>
+ % for my $backend (@{ stash('frequent') // [] }) {
+ %= include '_backend_line', user => $user, backend => $backend
+ % }
+ % }
% if (@{stash('suggestions') // []}) {
<div class="row">
<div class="col s12">