aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStruan Donald <struan@exo.org.uk>2017-09-05 14:49:29 +0100
committerStruan Donald <struan@exo.org.uk>2017-09-20 17:02:34 +0100
commit7f1717234c0315e231bb2a4f582287d68e976fea (patch)
tree9d520abca7025b14230f04d16811f2a7077ad004
parentebc2e1f227471ecaccd16eb897da27c193eddb65 (diff)
area stats page for staff users
Admin page to show some simple summary stats for an area. If the user has been assigned to an area then they will see the stats for that area. Superusers can pick which area they want to view. For mysociety/fixmystreetforcouncils#2
-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' %]