diff --git a/docs/screenshots/analytics-filters.png b/docs/screenshots/analytics-filters.png new file mode 100644 index 0000000..ba96998 Binary files /dev/null and b/docs/screenshots/analytics-filters.png differ diff --git a/docs/screenshots/analytics-overview.png b/docs/screenshots/analytics-overview.png index 3335dc8..4b4135c 100644 Binary files a/docs/screenshots/analytics-overview.png and b/docs/screenshots/analytics-overview.png differ diff --git a/docs/screenshots/audience-events-and-flags.png b/docs/screenshots/audience-events-and-flags.png index 7e57a07..465f107 100644 Binary files a/docs/screenshots/audience-events-and-flags.png and b/docs/screenshots/audience-events-and-flags.png differ diff --git a/docs/screenshots/custom-event-details.png b/docs/screenshots/custom-event-details.png index 5d2c524..6662adb 100644 Binary files a/docs/screenshots/custom-event-details.png and b/docs/screenshots/custom-event-details.png differ diff --git a/src/TheBuilder.WebAnalytics/Services/MockVercelAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/MockVercelAnalyticsClient.cs index 5c9c851..e1dda1b 100644 --- a/src/TheBuilder.WebAnalytics/Services/MockVercelAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/MockVercelAnalyticsClient.cs @@ -99,7 +99,13 @@ public Task> GetBreakdownAsync( IReadOnlyList rows = isUtm && scenario is not (MockAnalyticsScenario.Complete or MockAnalyticsScenario.Utm) ? [] : Breakdowns.GetValueOrDefault(dimension, []); - return Task.FromResult>(Filter(rows, search, limit, row => row.Value)); + var filtered = ApplyDimensionFilter(rows, query, dimension, row => row.Value); + var scale = QueryScale(connection, query, dimension); + var scaled = filtered.Select(row => new AnalyticsBreakdownRow( + row.Value, + Scale(row.PageViews, scale), + Scale(row.Visitors, scale))); + return Task.FromResult>(Filter(scaled, search, limit, row => row.Value)); } public Task CountEventsAsync( @@ -112,7 +118,8 @@ public Task CountEventsAsync( cancellationToken.ThrowIfCancellationRequested(); if (!HasEvents(connection)) return Task.FromResult(new AnalyticsEventTotals(0, 0)); var row = Events.FirstOrDefault(item => string.Equals(item.EventName, eventName, StringComparison.OrdinalIgnoreCase)); - var scale = eventDataFilter is null ? 1d : 0.42d; + var scale = QueryScale(connection, query, AnalyticsDimension.EventName) + * (eventDataFilter is null ? 1d : 0.42d); return Task.FromResult(row is null ? new AnalyticsEventTotals(0, 0) : new AnalyticsEventTotals(Scale(row.Count, scale), Scale(row.Visitors, scale))); @@ -127,7 +134,13 @@ public Task> GetEventsAsync( { cancellationToken.ThrowIfCancellationRequested(); IReadOnlyList rows = HasEvents(connection) ? Events : []; - return Task.FromResult>(Filter(rows, search, limit, row => row.EventName)); + var filtered = ApplyDimensionFilter(rows, query, AnalyticsDimension.EventName, row => row.EventName); + var scale = QueryScale(connection, query, AnalyticsDimension.EventName); + var scaled = filtered.Select(row => new AnalyticsEventRow( + row.EventName, + Scale(row.Count, scale), + Scale(row.Visitors, scale))); + return Task.FromResult>(Filter(scaled, search, limit, row => row.EventName)); } public Task> GetFlagsAsync( @@ -146,7 +159,11 @@ public Task> GetFlagsAsync( "personalised-homepage" => [new("default", 2410, 1530), new("industry", 1390, 870), new("returning-visitor", 960, 610)], _ => FlagKeys }; - return Task.FromResult>(rows.Take(Math.Max(0, limit)).ToArray()); + var scale = QueryScale(connection, query); + return Task.FromResult>(rows + .Take(Math.Max(0, limit)) + .Select(row => new AnalyticsFlagRow(row.Value, Scale(row.PageViews, scale), Scale(row.Visitors, scale))) + .ToArray()); } public Task> GetEventPropertyNamesAsync( @@ -207,14 +224,30 @@ private static T[] Filter(IEnumerable rows, string? search, int limit, Fun return filtered.Take(Math.Max(0, limit)).ToArray(); } - private static double QueryScale(VercelAnalyticsConnection connection, AnalyticsQuery query) + private static IEnumerable ApplyDimensionFilter( + IEnumerable rows, + AnalyticsQuery query, + AnalyticsDimension dimension, + Func value) + { + var selected = query.Filters?.FirstOrDefault(filter => filter.Dimension == dimension); + return selected is null + ? rows + : rows.Where(row => string.Equals(value(row), selected.Value, StringComparison.OrdinalIgnoreCase)); + } + + private static double QueryScale( + VercelAnalyticsConnection connection, + AnalyticsQuery query, + AnalyticsDimension? excludedDimension = null) { var scale = string.IsNullOrWhiteSpace(query.RequestPath) ? 1d : 0.36d; var rangeScale = Math.Clamp((query.To - query.From).TotalDays / 30d, 0.03d, 24d); var growthScale = Scenario(connection) is MockAnalyticsScenario.Complete ? DemoGrowthScale(query.To) : 1d; - return scale * rangeScale * Math.Pow(0.62d, query.Filters?.Count ?? 0) * growthScale; + var filterCount = query.Filters?.Count(filter => filter.Dimension != excludedDimension) ?? 0; + return scale * rangeScale * Math.Pow(0.62d, filterCount) * growthScale; } private static double DemoGrowthScale(DateTimeOffset periodEnd) diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/MockVercelAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/MockVercelAnalyticsClientTests.cs index 25b81db..d7d626c 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/MockVercelAnalyticsClientTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/MockVercelAnalyticsClientTests.cs @@ -109,6 +109,44 @@ public async Task Demo_totals_increase_slightly_over_the_previous_period() Assert.InRange(pageViewGrowth, 1.01d, 1.04d); } + [Fact] + public async Task Demo_filters_update_matching_and_cross_dimension_reports() + { + var client = new MockVercelAnalyticsClient(); + var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; + var baseline = CreateQuery(); + var filtered = baseline with + { + Filters = [new AnalyticsFilter(AnalyticsDimension.Country, "DK")] + }; + + var baselineDevices = await client.GetBreakdownAsync( + connection, baseline, AnalyticsDimension.DeviceType, 10, null, CancellationToken.None); + var filteredDevices = await client.GetBreakdownAsync( + connection, filtered, AnalyticsDimension.DeviceType, 10, null, CancellationToken.None); + var filteredCountries = await client.GetBreakdownAsync( + connection, filtered, AnalyticsDimension.Country, 10, null, CancellationToken.None); + + Assert.All(filteredDevices.Zip(baselineDevices), pair => + Assert.True(pair.First.Visitors < pair.Second.Visitors)); + Assert.Equal("DK", Assert.Single(filteredCountries).Value); + } + + [Fact] + public async Task Demo_event_filter_returns_only_the_selected_event() + { + var client = new MockVercelAnalyticsClient(); + var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; + var query = CreateQuery() with + { + Filters = [new AnalyticsFilter(AnalyticsDimension.EventName, "Demo requested")] + }; + + var events = await client.GetEventsAsync(connection, query, 10, null, CancellationToken.None); + + Assert.Equal("Demo requested", Assert.Single(events).EventName); + } + [Fact] public async Task Router_serves_mock_reports_without_contacting_Vercel() {