Skip to content

Commit 447a87d

Browse files
committed
Add some dashboard data
1 parent fa79570 commit 447a87d

File tree

11 files changed

+192
-7
lines changed

11 files changed

+192
-7
lines changed

LinkDotNet.Blog.IntegrationTests/Web/Pages/BlogPostPageTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ private void RegisterComponents(TestContextBase ctx, ILocalStorageService localS
8585
ctx.Services.AddScoped(_ => localStorageService ?? new Mock<ILocalStorageService>().Object);
8686
ctx.Services.AddScoped(_ => new Mock<IToastService>().Object);
8787
ctx.Services.AddScoped(_ => new Mock<IHeadElementHelper>().Object);
88+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
8889
}
8990
}
9091
}

LinkDotNet.Blog.IntegrationTests/Web/Pages/IndexTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ private void RegisterComponents(TestContextBase ctx)
159159
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
160160
ctx.Services.AddScoped(_ => CreateSampleAppConfiguration());
161161
ctx.Services.AddScoped(_ => new Mock<IHeadElementHelper>().Object);
162+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
162163
}
163164
}
164165
}

LinkDotNet.Blog.IntegrationTests/Web/Pages/SearchByTagTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using FluentAssertions;
66
using LinkDotNet.Blog.TestUtilities;
77
using LinkDotNet.Blog.Web.Pages;
8+
using LinkDotNet.Blog.Web.Shared;
89
using LinkDotNet.Domain;
910
using LinkDotNet.Infrastructure.Persistence;
1011
using Microsoft.Extensions.DependencyInjection;
@@ -25,6 +26,7 @@ public async Task ShouldOnlyDisplayTagsGivenByParameter()
2526
await AddBlogPostWithTagAsync("Tag 2");
2627
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
2728
ctx.Services.AddScoped(_ => new Mock<IHeadElementHelper>().Object);
29+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
2830
var cut = ctx.RenderComponent<SearchByTag>(p => p.Add(s => s.Tag, "Tag 1"));
2931
cut.WaitForState(() => cut.FindAll(".blog-card").Any());
3032

@@ -40,6 +42,7 @@ public async Task ShouldHandleSpecialCharacters()
4042
await AddBlogPostWithTagAsync("C#");
4143
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
4244
ctx.Services.AddScoped(_ => new Mock<IHeadElementHelper>().Object);
45+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
4346
var cut = ctx.RenderComponent<SearchByTag>(p => p.Add(s => s.Tag, Uri.EscapeDataString("C#")));
4447
cut.WaitForState(() => cut.FindAll(".blog-card").Any());
4548

LinkDotNet.Blog.IntegrationTests/Web/Pages/SearchTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using LinkDotNet.Domain;
99
using LinkDotNet.Infrastructure.Persistence;
1010
using Microsoft.Extensions.DependencyInjection;
11+
using Moq;
1112
using Xunit;
1213

1314
namespace LinkDotNet.Blog.IntegrationTests.Web.Pages
@@ -23,6 +24,7 @@ public async Task ShouldFindBlogPostWhenTitleMatches()
2324
await Repository.StoreAsync(blogPost2);
2425
using var ctx = new TestContext();
2526
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
27+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
2628

2729
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Title 1"));
2830

@@ -41,6 +43,7 @@ public async Task ShouldFindBlogPostWhenTagMatches()
4143
await Repository.StoreAsync(blogPost2);
4244
using var ctx = new TestContext();
4345
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
46+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
4447

4548
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Cat"));
4649

@@ -57,6 +60,7 @@ public async Task ShouldUnescapeQuery()
5760
await Repository.StoreAsync(blogPost1);
5861
using var ctx = new TestContext();
5962
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
63+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
6064

6165
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Title%201"));
6266

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,38 @@
11
@page "/dashboard"
2+
@using LinkDotNet.Blog.Web.Shared.Admin.Dashboard
3+
@inject IDashboardService dashboardService
24
@attribute [Authorize]
35

4-
<div class="page">
6+
<div class="page container-fluid ms-3">
57
<h3>Dashboard</h3>
68
<div class="container-fluid">
9+
<div class="row my-2">
10+
<div class="col-auto">
11+
<DashboardCard Text="Total Users:"
12+
TotalAmount="@data.TotalAmountOfUsers"
13+
AmountLast30Days="@data.AmountOfUsersLast30Days"></DashboardCard>
14+
</div>
15+
16+
<div class="col-auto">
17+
<DashboardCard Text="Total Clicks:"
18+
TotalAmount="@data.TotalPageClicks"
19+
AmountLast30Days="@data.PageClicksLast30Days"></DashboardCard>
20+
</div>
21+
</div>
722
<div class="row">
823
<div class="col-auto">
9-
<div class="card px-3 py-3">
10-
<h5>Total amount of posts: </h5>
11-
<h5 style="text-align: center"> 12 </h5>
12-
</div>
24+
<VisitCountPerPage PageVisitCount="@data.BlogPostVisitCount"></VisitCountPerPage>
1325
</div>
1426
</div>
1527
</div>
16-
</div>
28+
</div>
29+
30+
@code {
31+
32+
private DashboardData data = new();
33+
34+
protected override async Task OnInitializedAsync()
35+
{
36+
data = await dashboardService.GetDashboardDataAsync();
37+
}
38+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
4+
namespace LinkDotNet.Blog.Web.Pages.Admin
5+
{
6+
public class DashboardData
7+
{
8+
public int TotalAmountOfUsers { get; set; }
9+
10+
public int AmountOfUsersLast30Days { get; set; }
11+
12+
public int TotalPageClicks { get; set; }
13+
14+
public int PageClicksLast30Days { get; set; }
15+
16+
public IOrderedEnumerable<KeyValuePair<string, int>> BlogPostVisitCount { get; set; }
17+
}
18+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using LinkDotNet.Domain;
6+
using LinkDotNet.Infrastructure.Persistence;
7+
8+
namespace LinkDotNet.Blog.Web.Pages.Admin
9+
{
10+
public interface IDashboardService
11+
{
12+
Task<DashboardData> GetDashboardDataAsync();
13+
}
14+
15+
public class DashboardService : IDashboardService
16+
{
17+
private readonly IRepository<UserRecord> userRecordRepository;
18+
19+
public DashboardService(
20+
IRepository<UserRecord> userRecordRepository)
21+
{
22+
this.userRecordRepository = userRecordRepository;
23+
}
24+
25+
public async Task<DashboardData> GetDashboardDataAsync()
26+
{
27+
var records = (await userRecordRepository.GetAllAsync()).ToList();
28+
var users = records.GroupBy(r => r.IpHash).Count();
29+
var users30Days = records
30+
.Where(r => r.DateTimeUtcClicked >= DateTime.UtcNow.AddDays(-30))
31+
.GroupBy(r => r.IpHash).Count();
32+
33+
var clicks = records.Count;
34+
var clicks30Days = records.Count(r => r.DateTimeUtcClicked >= DateTime.UtcNow.AddDays(-30));
35+
36+
var visitCount = GetPageVisitCount(records);
37+
38+
return new DashboardData
39+
{
40+
TotalAmountOfUsers = users,
41+
AmountOfUsersLast30Days = users30Days,
42+
TotalPageClicks = clicks,
43+
PageClicksLast30Days = clicks30Days,
44+
BlogPostVisitCount = visitCount,
45+
};
46+
}
47+
48+
private static IOrderedEnumerable<KeyValuePair<string, int>> GetPageVisitCount(IEnumerable<UserRecord> records)
49+
{
50+
return records
51+
.Where(u => u.UrlClicked.Contains("blogPost"))
52+
.GroupBy(u => u.UrlClicked)
53+
.ToDictionary(k => k.Key, v => v.Count())
54+
.OrderByDescending(d => d.Value);
55+
}
56+
}
57+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<div class="card">
2+
<div class="card-body">
3+
<h5 class="card-title">@Text</h5>
4+
<h4 class="card-text">@TotalAmount</h4>
5+
<p class="card-text">@AmountLast30Days since last 30 days</p>
6+
</div>
7+
</div>
8+
@code {
9+
[Parameter]
10+
public int TotalAmount { get; set; }
11+
12+
[Parameter]
13+
public int AmountLast30Days { get; set; }
14+
15+
[Parameter]
16+
public string Text { get; set; }
17+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
@using LinkDotNet.Infrastructure.Persistence
2+
@using LinkDotNet.Domain
3+
@inject IRepository<BlogPost> blogPostRepository
4+
5+
<div class="card">
6+
<div class="card-header">Page Visit Counts</div>
7+
<div class="card-body">
8+
<table class="table table-striped">
9+
<tbody>
10+
<tr>
11+
<th>Title</th>
12+
<th>Clicks</th>
13+
</tr>
14+
@if (PageVisitCount != null)
15+
{
16+
@foreach (var pageVisit in blogPostToCountList)
17+
{
18+
<tr>
19+
<td>@pageVisit.Key</td>
20+
<td>@pageVisit.Value</td>
21+
</tr>
22+
}
23+
}
24+
</tbody>
25+
</table>
26+
</div>
27+
</div>
28+
29+
@code {
30+
[Parameter]
31+
public IOrderedEnumerable<KeyValuePair<string, int>> PageVisitCount { get; set; }
32+
33+
private List<KeyValuePair<string, int>> blogPostToCountList = new();
34+
35+
protected override async Task OnParametersSetAsync()
36+
{
37+
if (PageVisitCount == null)
38+
{
39+
return;
40+
}
41+
42+
foreach (var (blogPost, clickCount) in PageVisitCount)
43+
{
44+
var blogPostId = blogPost[(blogPost.IndexOf('/') + 1)..];
45+
var blogPostTitle = (await blogPostRepository.GetByIdAsync(blogPostId)).Title;
46+
47+
blogPostToCountList.Add(new KeyValuePair<string, int>(blogPostTitle, clickCount));
48+
}
49+
}
50+
}

LinkDotNet.Blog.Web/Shared/UserRecordService.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using LinkDotNet.Domain;
44
using LinkDotNet.Infrastructure.Persistence;
55
using Microsoft.AspNetCore.Components;
6+
using Microsoft.AspNetCore.Components.Authorization;
67
using Microsoft.AspNetCore.Http;
78

89
namespace LinkDotNet.Blog.Web.Shared
@@ -17,19 +18,28 @@ public class UserRecordService : IUserRecordService
1718
private readonly IRepository<UserRecord> userRecordRepository;
1819
private readonly IHttpContextAccessor httpContextAccessor;
1920
private readonly NavigationManager navigationManager;
21+
private readonly AuthenticationStateProvider authenticationStateProvider;
2022

2123
public UserRecordService(
2224
IRepository<UserRecord> userRecordRepository,
2325
IHttpContextAccessor httpContextAccessor,
24-
NavigationManager navigationManager)
26+
NavigationManager navigationManager,
27+
AuthenticationStateProvider authenticationStateProvider)
2528
{
2629
this.userRecordRepository = userRecordRepository;
2730
this.httpContextAccessor = httpContextAccessor;
2831
this.navigationManager = navigationManager;
32+
this.authenticationStateProvider = authenticationStateProvider;
2933
}
3034

3135
public async Task StoreUserRecordAsync()
3236
{
37+
var userIdentity = (await authenticationStateProvider.GetAuthenticationStateAsync()).User.Identity;
38+
if (userIdentity == null || userIdentity.IsAuthenticated)
39+
{
40+
return;
41+
}
42+
3343
var httpContext = httpContextAccessor.HttpContext;
3444
var ipHash = httpContext.Connection.RemoteIpAddress?.GetHashCode() ?? 0;
3545

0 commit comments

Comments
 (0)