aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md2
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin.pm11
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/AreaStats.pm218
-rw-r--r--perllib/FixMyStreet/Cobrand/Default.pm1
-rw-r--r--perllib/FixMyStreet/TestMech.pm14
-rw-r--r--t/Mock/MapIt.pm10
-rw-r--r--t/app/controller/area_stats.t211
-rw-r--r--templates/web/base/admin/areastats/area.html72
-rw-r--r--templates/web/base/admin/areastats/index.html11
9 files changed, 546 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 176f52460..d2333a85e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,8 @@
## Releases
* Unreleased
+ - New features:
+ - Area summary statistics page in admin #1834
* v2.2 (13th September 2017)
- New features:
diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm
index ed40f4565..a47e74f19 100644
--- a/perllib/FixMyStreet/App/Controller/Admin.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin.pm
@@ -553,7 +553,7 @@ sub fetch_translations : Private {
$c->stash->{translations} = $translations;
}
-sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) {
+sub lookup_body : Private {
my ( $self, $c, $body_id ) = @_;
$c->stash->{body_id} = $body_id;
@@ -561,7 +561,14 @@ sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) {
$c->detach( '/page_error_404_not_found', [] )
unless $body;
$c->stash->{body} = $body;
-
+}
+
+sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) {
+ my ( $self, $c, $body_id ) = @_;
+
+ $c->forward('lookup_body');
+ my $body = $c->stash->{body};
+
if ($body->body_areas->first) {
my $example_postcode = mySociety::MaPit::call('area/example_postcode', $body->body_areas->first->area_id);
if ($example_postcode && ! ref $example_postcode) {
diff --git a/perllib/FixMyStreet/App/Controller/Admin/AreaStats.pm b/perllib/FixMyStreet/App/Controller/Admin/AreaStats.pm
new file mode 100644
index 000000000..932631cba
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/AreaStats.pm
@@ -0,0 +1,218 @@
+package FixMyStreet::App::Controller::Admin::AreaStats;
+use Moose;
+use namespace::autoclean;
+use List::Util qw(sum);
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ my $user = $c->user;
+
+ if ($user->is_superuser) {
+ $c->forward('/admin/fetch_all_bodies');
+ } elsif ( $user->from_body ) {
+ $c->forward('load_user_body', [ $user->from_body->id ]);
+ $c->stash->{body_id} = $user->from_body->id;
+ if ($user->area_id) {
+ $c->stash->{area_id} = $user->area_id;
+ $c->forward('setup_area');
+ $c->visit( 'stats' );
+ } else {
+ # visit body_stats so we load the list of child areas
+ $c->visit( 'body_stats', [ $user->from_body->id], [] );
+ }
+ } else {
+ $c->detach( '/page_error_404_not_found' );
+ }
+}
+
+sub check_user : Private {
+ my ( $self, $c, $body_id, $area_id ) = @_;
+
+ my $user = $c->user;
+
+ return if $user->is_superuser;
+
+ if ($body_id and $user->from_body->id eq $body_id) {
+ if (not $user->area_id) {
+ return;
+ } elsif ($area_id and $user->area_id eq $area_id) {
+ return;
+ }
+ }
+
+ $c->detach( '/page_error_404_not_found' );
+}
+
+sub setup_area : Private {
+ my ($self, $c) = @_;
+
+ my $area = mySociety::MaPit::call('area', $c->stash->{area_id} );
+ $c->detach( '/page_error_404_not_found' ) if $area->{error};
+ $c->stash->{area} = $area;
+}
+
+sub body_base : Chained('/') : PathPart('admin/areastats') : CaptureArgs(1) {
+ my ($self, $c, $body_id) = @_;
+
+ $c->forward('/admin/lookup_body', $body_id);
+ $c->stash->{areas} = mySociety::MaPit::call('area/children', [ $c->stash->{body_id} ] );
+}
+
+sub body_stats : Chained('body_base') : PathPart('') : Args(0) {
+ my ($self, $c) = @_;
+
+ if ($c->get_param('area')) {
+ $c->forward('check_user', [$c->stash->{body_id}, $c->get_param('area')]);
+ $c->stash->{area_id} = $c->get_param('area');
+ $c->forward('setup_area');
+ } else {
+ $c->forward('check_user', [$c->stash->{body_id}]);
+ }
+ $c->forward('stats');
+}
+
+sub stats : Private {
+ my ($self, $c) = @_;
+
+ my $date = DateTime->now->subtract(days => 30);
+
+ $c->forward('/admin/fetch_contacts');
+
+ $c->stash->{template} = 'admin/areastats/area.html';
+
+ my $dtf = $c->model('DB')->storage->datetime_parser;
+ my $time = $dtf->format_datetime($date);
+
+ my $params = {
+ 'me.confirmed' => { '>=', $time },
+ };
+
+ my %area_param = ();
+ if ($c->stash->{area}) {
+ my $area_id = $c->stash->{area_id};
+ $c->stash->{area_name} = $c->stash->{area}->{name};
+ $params->{'problem.areas'} = { like => "%,$area_id,%" };
+ %area_param = (
+ areas => { like => "%,$area_id,%" },
+ );
+ } else {
+ $c->stash->{area_name} = $c->stash->{body}->name;
+ }
+
+ my %by_category = map { $_->category => {} } $c->stash->{contacts}->all;
+ my %recent_by_category = map { $_->category => 0 } $c->stash->{contacts}->all;
+
+ my $state_map = {};
+
+ $state_map->{$_} = 'open' foreach FixMyStreet::DB::Result::Problem->open_states;
+ $state_map->{$_} = 'closed' foreach FixMyStreet::DB::Result::Problem->closed_states;
+ $state_map->{$_} = 'fixed' foreach FixMyStreet::DB::Result::Problem->fixed_states;
+ $state_map->{$_} = 'scheduled' foreach ('planned', 'action scheduled');
+
+ # current problems by category and state
+ my $problems = $c->model('DB::Problem')->to_body(
+ $c->stash->{body}
+ )->search(
+ \%area_param,
+ {
+ group_by => [ 'category', 'state' ],
+ select => [ 'category', 'state', { count => 'me.id' } ],
+ as => [ qw/category state state_count/ ],
+ }
+ );
+
+ while (my $p = $problems->next) {
+ my $meta_state = $state_map->{$p->state};
+ $by_category{$p->category}->{$meta_state} += $p->get_column('state_count');
+ }
+ $c->stash->{by_category} = \%by_category;
+
+ # problems this month by state
+ $c->stash->{$_} = 0 for values %$state_map;
+
+ $c->stash->{open} = $c->model('DB::Problem')->to_body(
+ $c->stash->{body}
+ )->search(
+ {
+ %area_param,
+ confirmed => { '>=' => $time },
+ }
+ )->count;
+
+ my $comments = $c->model('DB::Comment')->to_body(
+ $c->stash->{body}
+ )->search(
+ $params,
+ {
+ join => 'problem'
+ }
+ );
+
+ # you can have multiple comments with the same problem state so need to only count
+ # one instance.
+ my %state_seen = ();
+ while (my $comment = $comments->next) {
+ my $meta_state = $state_map->{$comment->problem_state};
+ my $key = $comment->problem->id . "-$meta_state";
+ next if $state_seen{$key};
+ $c->stash->{$meta_state} += 1;
+ $state_seen{$key} = 1;
+ }
+
+ $params = {
+ %area_param,
+ 'me.confirmed' => { '>=', $time },
+ };
+
+ # problems this month by category
+ my $recent_problems = $c->model('DB::Problem')->to_body(
+ $c->stash->{body}
+ )->search(
+ $params,
+ {
+ group_by => [ 'category' ],
+ select => [ 'category', { count => 'me.id' } ],
+ as => [ qw/category category_count/ ],
+ }
+ );
+
+ while (my $p = $recent_problems->next) {
+ $recent_by_category{$p->category} += $p->get_column('category_count');
+ }
+ $c->stash->{recent_by_category} = \%recent_by_category;
+
+ # average time to state change in last month
+ $params = {
+ %area_param,
+ 'problem.confirmed' => { '>=', $time },
+ };
+
+ $comments = $c->model('DB::Comment')->to_body(
+ $c->stash->{body}
+ )->search(
+ { %$params,
+ 'me.id' => \"= (select min(id) from comment where me.problem_id=comment.problem_id)",
+ 'me.problem_state' => { '!=' => 'confirmed' },
+ },
+ {
+ select => [
+ { avg => { extract => "epoch from me.confirmed-problem.confirmed" } },
+ ],
+ as => [ qw/time/ ],
+ join => 'problem'
+ }
+ )->first;
+ $c->stash->{average} = int( ($comments->get_column('time')||0)/ 60 / 60 / 24 + 0.5 );
+}
+
+sub load_user_body : Private {
+ my ($self, $c, $body_id) = @_;
+
+ $c->stash->{body} = $c->model('DB::Body')->find($body_id)
+ or $c->detach( '/page_error_404_not_found' );
+}
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm
index 250919d09..002d8e3da 100644
--- a/perllib/FixMyStreet/Cobrand/Default.pm
+++ b/perllib/FixMyStreet/Cobrand/Default.pm
@@ -637,6 +637,7 @@ sub admin_pages {
'summary' => [_('Summary'), 0],
'timeline' => [_('Timeline'), 5],
'stats' => [_('Stats'), 8],
+ 'areastats' => [_('Area Stats'), 14],
};
# There are some pages that only super users can see
diff --git a/perllib/FixMyStreet/TestMech.pm b/perllib/FixMyStreet/TestMech.pm
index 46f5344e2..4bad1d17b 100644
--- a/perllib/FixMyStreet/TestMech.pm
+++ b/perllib/FixMyStreet/TestMech.pm
@@ -720,4 +720,18 @@ sub get_photo_data {
};
}
+sub create_comment_for_problem {
+ my ( $mech, $problem, $user, $name, $text, $anonymous, $state, $problem_state, $params ) = @_;
+ $params ||= {};
+ $params->{problem_id} = $problem->id;
+ $params->{user_id} = $user->id;
+ $params->{name} = $name;
+ $params->{text} = $text;
+ $params->{anonymous} = $anonymous;
+ $params->{problem_state} = $problem_state;
+ $params->{state} = $state;
+ $params->{mark_fixed} = $problem_state && FixMyStreet::DB::Result::Problem->fixed_states()->{$problem_state} ? 1 : 0;
+
+ FixMyStreet::App->model('DB::Comment')->create($params);
+}
1;
diff --git a/t/Mock/MapIt.pm b/t/Mock/MapIt.pm
index 926d94b1e..6e3c5d673 100644
--- a/t/Mock/MapIt.pm
+++ b/t/Mock/MapIt.pm
@@ -92,8 +92,14 @@ sub dispatch_request {
sub (GET + /area/*) {
my ($self, $area) = @_;
- my $response = { "id" => $area, "name" => "Area $area", "type" => "UTA" };
- return $self->output($response);
+ my $response;
+ if ($area eq '999') {
+ $response = { code => 404, error => "No Area matches the given query." };
+ return [ 404, [ 'Content-Type' => 'application/json' ], [ $self->json->encode($response) ] ];
+ } else {
+ $response = { "id" => $area, "name" => "Area $area", "type" => "UTA" };
+ return $self->output($response);
+ }
},
sub (GET + /area/*/children) {
diff --git a/t/app/controller/area_stats.t b/t/app/controller/area_stats.t
new file mode 100644
index 000000000..6ea03cabf
--- /dev/null
+++ b/t/app/controller/area_stats.t
@@ -0,0 +1,211 @@
+use strict;
+use warnings;
+use Test::More;
+
+use FixMyStreet::TestMech;
+
+my $mech = FixMyStreet::TestMech->new;
+
+my $oxfordshire = $mech->create_body_ok(2237, 'Oxfordshire County Council');
+my $superuser = $mech->create_user_ok('superuser@example.com', name => 'Super User', is_superuser => 1);
+my $oxfordshireuser = $mech->create_user_ok('counciluser@example.com', name => 'Council User', from_body => $oxfordshire);
+
+$mech->create_contact_ok( body_id => $oxfordshire->id, category => 'Potholes', email => 'potholes@example.com' );
+$mech->create_contact_ok( body_id => $oxfordshire->id, category => 'Traffic lights', email => 'lights@example.com' );
+$mech->create_contact_ok( body_id => $oxfordshire->id, category => 'Litter', email => 'litter@example.com' );
+
+my $body_id = $oxfordshire->id;
+my $area_id = '20720';
+my $alt_area_id = '20721';
+
+$mech->create_problems_for_body(2, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Potholes' });
+$mech->create_problems_for_body(3, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Traffic lights' });
+$mech->create_problems_for_body(1, $oxfordshire->id, 'Title', { areas => ",$alt_area_id,6753,2237,", created => \'current_timestamp', category => 'Litter' });
+
+my @scheduled_problems = $mech->create_problems_for_body(7, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Traffic lights' });
+my @fixed_problems = $mech->create_problems_for_body(4, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Potholes' });
+my @closed_problems = $mech->create_problems_for_body(3, $oxfordshire->id, 'Title', { areas => ",$area_id,6753,2237,", created => \'current_timestamp', category => 'Traffic lights' });
+
+foreach my $problem (@scheduled_problems) {
+ $problem->update({ state => 'planned' });
+ $mech->create_comment_for_problem($problem, $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'planned', { confirmed => \'current_timestamp' });
+}
+
+foreach my $problem (@fixed_problems) {
+ $problem->update({ state => 'fixed - council' });
+ $mech->create_comment_for_problem($problem, $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'fixed', { confirmed => \'current_timestamp' });
+}
+
+foreach my $problem (@closed_problems) {
+ $problem->update({ state => 'closed' });
+ $mech->create_comment_for_problem($problem, $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'closed', { confirmed => \'current_timestamp' });
+}
+
+$mech->log_in_ok( $superuser->email );
+
+FixMyStreet::override_config {
+ MAPIT_URL => 'http://mapit.uk/',
+ ALLOWED_COBRANDS => [ 'oxfordshire' ],
+}, sub {
+ subtest 'superuser gets areas listed' => sub {
+ $mech->create_body_ok(1234, 'Some Other Council');
+ $mech->get_ok('/admin/areastats');
+ $mech->content_contains('Oxfordshire County Council', 'Oxfordshire is shown on the page');
+ $mech->content_contains('Some Other Council', 'Some other council is shown on the page');
+ };
+
+ subtest 'body user sees whole body stats page' => sub {
+ $mech->log_in_ok( $oxfordshireuser->email );
+ $mech->get_ok('/admin/areastats');
+ $mech->content_contains("Area stats for Oxfordshire County Council");
+ $mech->content_contains('Trowbridge');
+ $mech->content_contains('Bradford-on-Avon');
+ };
+
+ subtest 'area user can only see their area' => sub {
+ $oxfordshireuser->update({area_id => 20720});
+
+ $mech->get("/admin/areastats/$body_id");
+ is $mech->status, 404, 'area user cannot see parent area';
+
+ $mech->get("/admin/areastats/$body_id?area=20721");
+ is $mech->status, 404, 'area user cannot see another area';
+
+ $mech->get_ok('/admin/areastats');
+ $mech->text_contains('Area 20720', 'index page displays their area to area user');
+
+ $oxfordshireuser->update({area_id => undef});
+ };
+
+ subtest 'gets an area' => sub {
+ $mech->log_in_ok( $oxfordshireuser->email );
+ $mech->get_ok("/admin/areastats/$body_id?area=20720");
+ $mech->content_contains('Area 20720', 'Area name is shown on the page');
+
+ $mech->get('/admin/areastats/999');
+ is $mech->status, 404, 'Getting a non-existent body returns 404';
+
+ $mech->get("/admin/areastats/$body_id/999");
+ is $mech->status, 404, 'Getting a non-existent area returns 404';
+ };
+
+ subtest 'shows correct stats for ward' => sub {
+ $mech->get_ok("/admin/areastats/$body_id?area=20720");
+ $mech->content_contains('19 opened, 7 scheduled, 3 closed, 4 fixed');
+ $mech->text_contains('Potholes2004');
+ $mech->text_contains('Traffic lights3730');
+ $mech->text_contains('Litter0000');
+
+ $mech->text_contains('Potholes6');
+ $mech->text_contains('Traffic lights13');
+ };
+
+ subtest 'shows correct stats to area user' => sub {
+ $oxfordshireuser->update({area_id => 20720});
+
+ $mech->get_ok("/admin/areastats");
+ $mech->content_contains('19 opened, 7 scheduled, 3 closed, 4 fixed');
+ $mech->text_contains('Potholes2004');
+ $mech->text_contains('Traffic lights3730');
+ $mech->text_contains('Litter0000');
+
+ $mech->text_contains('Potholes6');
+ $mech->text_contains('Traffic lights13');
+
+ $oxfordshireuser->update({area_id => undef});
+ };
+
+ subtest 'shows correct stats for ward using area param' => sub {
+ $mech->get_ok("/admin/areastats/$body_id?area=20720");
+ $mech->content_contains('19 opened, 7 scheduled, 3 closed, 4 fixed');
+ $mech->text_contains('Potholes2004');
+ $mech->text_contains('Traffic lights3730');
+ $mech->text_contains('Litter0000');
+
+ $mech->text_contains('Potholes6');
+ $mech->text_contains('Traffic lights13');
+ };
+
+ subtest 'shows correct stats for council' => sub {
+ $mech->get_ok("/admin/areastats/$body_id");
+ $mech->content_contains('20 opened, 7 scheduled, 3 closed, 4 fixed');
+ $mech->text_contains('Potholes2004');
+ $mech->text_contains('Traffic lights3730');
+ $mech->text_contains('Litter1000');
+
+ $mech->text_contains('Potholes6');
+ $mech->text_contains('Traffic lights13');
+ };
+
+ subtest 'shows average correctly' => sub {
+ $fixed_problems[0]->update({ confirmed => DateTime->now->subtract(days => 2) });
+ $fixed_problems[1]->update({ confirmed => DateTime->now->subtract(days => 3) });
+ $fixed_problems[2]->update({ confirmed => DateTime->now->subtract(days => 7) });
+ $fixed_problems[3]->update({ confirmed => DateTime->now->subtract(days => 4) });
+ $scheduled_problems[0]->update({ confirmed => DateTime->now->subtract(days => 2) });
+ $scheduled_problems[1]->update({ confirmed => DateTime->now->subtract(days => 4) });
+ $scheduled_problems[2]->update({ confirmed => DateTime->now->subtract(days => 6) });
+ $scheduled_problems[3]->update({ confirmed => DateTime->now->subtract(days => 7) });
+ $scheduled_problems[4]->update({ confirmed => DateTime->now->subtract(days => 1) });
+ $scheduled_problems[6]->update({ confirmed => DateTime->now->subtract(days => 1) });
+ $closed_problems[0]->update({ confirmed => DateTime->now->subtract(days => 6) });
+ $closed_problems[1]->update({ confirmed => DateTime->now->subtract(days => 9) });
+ $closed_problems[2]->update({ confirmed => DateTime->now->subtract(days => 12) });
+
+ $mech->get_ok("/admin/areastats/$body_id?area=20720");
+ $mech->text_contains('average time between issue being opened and set to another status was 5 days');
+ };
+
+ subtest 'shows this month stats correctly' => sub {
+ $fixed_problems[0]->update({ confirmed => DateTime->now->subtract(days => 50) });
+ $fixed_problems[1]->update({ confirmed => DateTime->now->subtract(days => 50) });
+ $scheduled_problems[1]->update({ confirmed => DateTime->now->subtract(days => 50) });
+ $scheduled_problems[2]->update({ confirmed => DateTime->now->subtract(days => 50) });
+
+ $mech->get_ok("/admin/areastats/$body_id?area=20720");
+
+ $mech->content_contains('15 opened, 7 scheduled, 3 closed, 4 fixed');
+ $mech->text_contains('Potholes2004');
+ $mech->text_contains('Traffic lights3730');
+
+ $mech->text_contains('Potholes4');
+ $mech->text_contains('Traffic lights11');
+
+ $mech->text_contains('average time between issue being opened and set to another status was 5 days');
+ };
+
+ subtest 'ignores multiple comments with the same state' => sub {
+ $mech->create_comment_for_problem($scheduled_problems[0], $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'planned', { confirmed => \'current_timestamp' });
+ $mech->get_ok("/admin/areastats/$body_id?area=20720");
+
+ $mech->content_contains('15 opened, 7 scheduled, 3 closed, 4 fixed');
+ $mech->text_contains('Potholes2004');
+ $mech->text_contains('Traffic lights3730');
+ };
+
+ subtest 'average is only to first state change' => sub {
+ for my $i (0..4) {
+ $scheduled_problems[$i]->comments->first->update({ confirmed => $scheduled_problems[$i]->confirmed });
+ $mech->create_comment_for_problem($scheduled_problems[$i], $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'fixed', { confirmed => \'current_timestamp' });
+ }
+
+ $mech->get_ok("/admin/areastats/$body_id?area=20720");
+ $mech->text_contains('average time between issue being opened and set to another status was 4 days');
+ };
+
+ subtest 'average only includes actual state changes' => sub {
+ my @probs = $mech->create_problems_for_body(2, $oxfordshire->id, 'Title',
+ { areas => ",$area_id,6753,2237,", created => DateTime->now->subtract(days => 12), confirmed => DateTime->now->subtract(days => 12), category => 'Potholes' });
+ $mech->create_comment_for_problem($probs[0], $oxfordshireuser, 'Title', 'text', 0, 'confirmed', 'confirmed', { confirmed => \'current_timestamp' });
+
+ $mech->get_ok("/admin/areastats/$body_id?area=20720");
+ $mech->text_contains('average time between issue being opened and set to another status was 4 days');
+ };
+};
+
+END {
+ FixMyStreet::DB->resultset('UserPlannedReport')->delete_all;
+ $mech->delete_user( $superuser );
+ $mech->delete_user( $oxfordshireuser );
+ done_testing();
+}
diff --git a/templates/web/base/admin/areastats/area.html b/templates/web/base/admin/areastats/area.html
new file mode 100644
index 000000000..1b88b5885
--- /dev/null
+++ b/templates/web/base/admin/areastats/area.html
@@ -0,0 +1,72 @@
+[% INCLUDE 'admin/header.html' title=tprintf(('Area stats for %s'), area_name) -%]
+<p>
+[% loc('There are currently:') %]
+</p>
+
+<table>
+ <tr>
+ <th></th>
+ <th>[% loc('Open') %]</th>
+ <th>[% loc('Scheduled') %]</th>
+ <th>[% loc('Closed') %]</th>
+ <th>[% loc('Fixed') %]</th>
+ </tr>
+ [% FOR k IN by_category.keys.sort %]
+ <tr>
+ <td>[% k %]</td>
+ <td>[% by_category.$k.open OR 0 %]</td>
+ <td>[% by_category.$k.scheduled OR 0 %]</td>
+ <td>[% by_category.$k.closed OR 0 %]</td>
+ <td>[% by_category.$k.fixed OR 0 %]</td>
+ </tr>
+ [% END %]
+
+</table>
+
+<p>
+[% loc('Issues in the last month:') %]
+</p>
+
+<p>
+[% tprintf(
+ loc('%d opened, %d scheduled, %d closed, %d fixed'),
+ open,
+ scheduled,
+ closed,
+ fixed
+ );
+%]
+</p>
+
+<table>
+ [% FOR k IN recent_by_category.keys.sort %]
+ <tr>
+ <td>[% k %]</td>
+ <td>[% recent_by_category.$k OR 0 %]</td>
+ </tr>
+ [% END %]
+</table>
+
+[% IF average > 0 %]
+<p>[% tprintf(loc('In the last month – average time between issue being opened and set to another status was %s days'), average) %]</p>
+[% ELSE %]
+<p>[% loc('In the last month no problems changed state') %]</p>
+[% END %]
+
+
+[% IF NOT c.user.area_id %]
+<p>
+<form action="" method="GET">
+[% loc('Show stats for:') %]
+ <select name="area">
+ <option value="">[% loc('Whole council') %]</option>
+ [% FOR area IN areas.values.sort('name')%]
+ <option value="[% area.id %]">[% area.name %]</option>
+ [% END %]
+ </select>
+ <input type="submit" value="Go">
+</form>
+</p>
+[% END %]
+
+[% INCLUDE 'admin/footer.html' %]
diff --git a/templates/web/base/admin/areastats/index.html b/templates/web/base/admin/areastats/index.html
new file mode 100644
index 000000000..1ebde20e7
--- /dev/null
+++ b/templates/web/base/admin/areastats/index.html
@@ -0,0 +1,11 @@
+[% INCLUDE 'admin/header.html' title=('Area Stats') -%]
+
+<ul>
+ [% FOR body IN bodies %]
+ <li>
+ <a href="[% c.uri_for('', body.id) %]">[% body.name %]</a>
+ </li>
+ [% END %]
+</ul>
+
+[% INCLUDE 'admin/footer.html' %]