diff --git a/CHANGELOG.md b/CHANGELOG.md index bcea9cbc..b8cfd97b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Fixed +- Location statistics now share exact ASCII-trimmed, parent-scoped grouping and combine “East Macedonia and Thrace” with “Eastern Macedonia and Thrace” only under “Greece”. Parent scoping can increase counts; the region correction can decrease them. Both Timeline views show missing-parent sections and safely encode labels; tied visits select one deterministic settlement coordinate (#573). See [Timeline statistics](docs/06-Timeline.md#statistics-grouping) for sources and the remaining string-only ambiguity: identical names within identical parents cannot be distinguished, while other alternate labels may still split one entity. Stored values and released-Mobile API shapes remain unchanged; no migration or provider calls are added. - Geoapify Location enrichment now stores street then house number, keeps settlement and state at their documented levels, and retains the independent provider line. Location maps, tables, timelines, groups and edit summaries prioritize structured addresses and show nearby feature metadata beneath them (#572). - CSV history exports use an explicit-offset enrichment timestamp so valid retained provenance survives backend round trips (#572). - Backend history imports preserve internal newlines and tabs in retained provider address lines; GPX/KML round trips normalize line endings to LF (#572). diff --git a/Services/LocationStatsService.cs b/Services/LocationStatsService.cs index dd20e5fb..ee2bbd74 100644 --- a/Services/LocationStatsService.cs +++ b/Services/LocationStatsService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using System.Runtime.CompilerServices; using Wayfarer.Models; using Wayfarer.Models.Dtos; @@ -24,100 +25,77 @@ public LocationStatsService(ApplicationDbContext db) _db = db; } - public async Task GetStatsForUserAsync(string userId) - { - var userLocations = _db.Locations.Where(l => l.UserId == userId); - - var totalLocations = await userLocations.CountAsync(); - var distinctCountries = await userLocations - .Where(l => !string.IsNullOrEmpty(l.Country)) - .Select(l => l.Country).Distinct().CountAsync(); - var distinctCities = await userLocations - .Where(l => !string.IsNullOrEmpty(l.Place)) - .Select(l => l.Place).Distinct().CountAsync(); - var distinctRegions = await userLocations - .Where(l => !string.IsNullOrEmpty(l.Region)) - .Select(l => l.Region).Distinct().CountAsync(); + /// Summarizes all records using their UTC Timestamp. + public Task GetStatsForUserAsync(string userId) => + ReadSummaryAsync(StatisticsScope(false), [userId]); - var fromDate = await userLocations.MinAsync(l => (DateTime?)l.Timestamp); + /// Summarizes the inclusive LocalTimestamp window. + public Task GetStatsForDateRangeAsync(string userId, DateTime startDate, DateTime endDate) => + ReadSummaryAsync(StatisticsScope(true), [userId, startDate, endDate]); - var toDate = await userLocations.MaxAsync(l => (DateTime?)l.Timestamp); + /// Returns all-time groups, using Timestamp for visits and representatives. + public Task GetDetailedStatsForUserAsync(string userId) => + ReadDetailsAsync(StatisticsScope(false), [userId]); - return new UserLocationStatsDto - { - TotalLocations = totalLocations, - CountriesVisited = distinctCountries, - CitiesVisited = distinctCities, - RegionsVisited = distinctRegions, - FromDate = fromDate, - ToDate = toDate - }; - } + /// Returns groups scoped inclusively by LocalTimestamp, also used for visit dates. + public Task GetDetailedStatsForDateRangeAsync( + string userId, DateTime startDate, DateTime endDate) => + ReadDetailsAsync(StatisticsScope(true), [userId, startDate, endDate]); /// - /// Gets statistics for a specific date range (day, month, or year) + /// Shared read-only projection for every statistics query. PostgreSQL C collation keeps + /// component equality exact. btrim removes only the six authorized ASCII characters; + /// empty strings represent missing components internally and in the legacy DTOs. + /// Only trusted SQL fragments vary; user IDs and inclusive bounds remain parameters. /// - /// User ID - /// Start date (UTC) - /// End date (UTC) - /// Statistics for the date range - public async Task GetStatsForDateRangeAsync(string userId, DateTime startDate, DateTime endDate) + private static string StatisticsScope(bool dateRange) { - var userLocations = _db.Locations.Where(l => l.UserId == userId - && l.LocalTimestamp >= startDate - && l.LocalTimestamp <= endDate); - - var totalLocations = await userLocations.CountAsync(); - var distinctCountries = await userLocations - .Where(l => !string.IsNullOrEmpty(l.Country)) - .Select(l => l.Country).Distinct().CountAsync(); - var distinctCities = await userLocations - .Where(l => !string.IsNullOrEmpty(l.Place)) - .Select(l => l.Place).Distinct().CountAsync(); - var distinctRegions = await userLocations - .Where(l => !string.IsNullOrEmpty(l.Region)) - .Select(l => l.Region).Distinct().CountAsync(); - - var fromDate = await userLocations.MinAsync(l => (DateTime?)l.LocalTimestamp); - var toDate = await userLocations.MaxAsync(l => (DateTime?)l.LocalTimestamp); - - return new UserLocationStatsDto - { - TotalLocations = totalLocations, - CountriesVisited = distinctCountries, - CitiesVisited = distinctCities, - RegionsVisited = distinctRegions, - FromDate = fromDate, - ToDate = toDate - }; + var timestamp = dateRange ? "LocalTimestamp" : "Timestamp"; + var bounds = dateRange ? "AND \"LocalTimestamp\" >= {1} AND \"LocalTimestamp\" <= {2}" : ""; + return $$""" + WITH trimmed AS ( + SELECT "Id", "Coordinates", "{{timestamp}}" AS "VisitTime", + btrim(COALESCE("Country", ''), E'\x20\x09\x0A\x0B\x0C\x0D') COLLATE "C" AS "Country", + btrim(COALESCE("Region", ''), E'\x20\x09\x0A\x0B\x0C\x0D') COLLATE "C" AS "Region", + btrim(COALESCE("Place", ''), E'\x20\x09\x0A\x0B\x0C\x0D') COLLATE "C" AS "Place" + FROM "Locations" WHERE "UserId" = {0} {{bounds}} + ), scoped AS ( + SELECT "Id", "Coordinates", "VisitTime", "Country", "Place", + (CASE WHEN "Country" = 'Greece' AND "Region" = 'East Macedonia and Thrace' + THEN 'Eastern Macedonia and Thrace' ELSE "Region" END) COLLATE "C" AS "Region" + FROM trimmed + ) + """; } - /// - /// Gets detailed statistics for all user locations including country details, regions, and cities - /// - /// User ID - /// Detailed statistics with arrays of country names, regions, and cities - public async Task GetDetailedStatsForUserAsync(string userId) + /// Counts distinct component tuples in PostgreSQL without loading Location entities. + private async Task ReadSummaryAsync(string scope, object[] parameters) { - var userLocations = _db.Locations.Where(l => l.UserId == userId); - - var totalLocations = await userLocations.CountAsync(); - - // OPTIMIZED: Get country details with coordinate averages calculated in database using PostGIS - // Using raw SQL for PostGIS functions - cast geography to geometry to use ST_X/ST_Y - var countryGroupsSql = await _db.Database.SqlQueryRaw(@" - SELECT - ""Country"", - MIN(""Timestamp"") as ""FirstVisit"", - MAX(""Timestamp"") as ""LastVisit"", - COUNT(*)::integer as ""VisitCount"", - AVG(ST_X(""Coordinates""::geometry)) as ""AvgLongitude"", - AVG(ST_Y(""Coordinates""::geometry)) as ""AvgLatitude"" - FROM ""Locations"" - WHERE ""UserId"" = {0} AND ""Country"" IS NOT NULL AND ""Country"" != '' - GROUP BY ""Country"" - ", userId).ToListAsync(); + var rows = await _db.Database.SqlQuery(FormattableStringFactory.Create(scope + """ + + SELECT COUNT(*)::integer AS "TotalLocations", + COUNT(DISTINCT "Country") FILTER (WHERE "Country" <> '')::integer AS "CountriesVisited", + COUNT(DISTINCT ("Country", "Region")) FILTER (WHERE "Region" <> '')::integer AS "RegionsVisited", + COUNT(DISTINCT ("Country", "Region", "Place")) FILTER (WHERE "Place" <> '')::integer AS "CitiesVisited", + MIN("VisitTime") AS "FromDate", MAX("VisitTime") AS "ToDate" + FROM scoped + """, parameters)).ToListAsync(); + return rows.Single(); + } + /// Aggregates visits in PostgreSQL and maps only grouped results to unchanged DTOs. + private async Task ReadDetailsAsync(string scope, object[] parameters) + { + var summary = await ReadSummaryAsync(scope, parameters); + var totalLocations = summary.TotalLocations; + var countryGroupsSql = await _db.Database.SqlQuery(FormattableStringFactory.Create(scope + """ + + SELECT "Country", MIN("VisitTime") AS "FirstVisit", MAX("VisitTime") AS "LastVisit", + COUNT(*)::integer AS "VisitCount", + AVG(ST_X("Coordinates"::geometry) ORDER BY "Id") AS "AvgLongitude", + AVG(ST_Y("Coordinates"::geometry) ORDER BY "Id") AS "AvgLatitude" + FROM scoped WHERE "Country" <> '' GROUP BY "Country" + """, parameters)).ToListAsync(); // Detect home country: country with >40% of total visits or significantly more than average var averageVisitCount = countryGroupsSql.Any() ? countryGroupsSql.Average(c => c.VisitCount) : 0; var homeCountryThreshold = Math.Max(totalLocations * 0.4, averageVisitCount * 3); @@ -134,23 +112,17 @@ GROUP BY ""Country"" }) .OrderByDescending(c => c.IsHomeCountry) .ThenByDescending(c => c.VisitCount) + .ThenBy(c => c.Name, StringComparer.Ordinal) .ToList(); - // OPTIMIZED: Get region details with coordinate averages calculated in database using PostGIS - var regionGroupsSql = await _db.Database.SqlQueryRaw(@" - SELECT - ""Region"", - ""Country"", - MIN(""Timestamp"") as ""FirstVisit"", - MAX(""Timestamp"") as ""LastVisit"", - COUNT(*)::integer as ""VisitCount"", - AVG(ST_X(""Coordinates""::geometry)) as ""AvgLongitude"", - AVG(ST_Y(""Coordinates""::geometry)) as ""AvgLatitude"" - FROM ""Locations"" - WHERE ""UserId"" = {0} AND ""Region"" IS NOT NULL AND ""Region"" != '' - GROUP BY ""Region"", ""Country"" - ", userId).ToListAsync(); + var regionGroupsSql = await _db.Database.SqlQuery(FormattableStringFactory.Create(scope + """ + SELECT "Country", "Region", MIN("VisitTime") AS "FirstVisit", MAX("VisitTime") AS "LastVisit", + COUNT(*)::integer AS "VisitCount", + AVG(ST_X("Coordinates"::geometry) ORDER BY "Id") AS "AvgLongitude", + AVG(ST_Y("Coordinates"::geometry) ORDER BY "Id") AS "AvgLatitude" + FROM scoped WHERE "Region" <> '' GROUP BY "Country", "Region" + """, parameters)).ToListAsync(); var regions = regionGroupsSql .Select(r => new RegionVisitDetail { @@ -161,44 +133,23 @@ GROUP BY ""Country"" VisitCount = r.VisitCount, Coordinates = new NetTopologySuite.Geometries.Point(r.AvgLongitude, r.AvgLatitude) { SRID = 4326 } }) - .OrderBy(r => r.CountryName) - .ThenBy(r => r.Name) + .OrderBy(r => r.CountryName, StringComparer.Ordinal) + .ThenBy(r => r.Name, StringComparer.Ordinal) .ToList(); - // OPTIMIZED: Get city details with representative coordinate (from most recent visit) - // This avoids loading thousands of coordinates into memory - var cityGroupsSql = await _db.Database.SqlQueryRaw(@" - WITH CityLatest AS ( - SELECT - ""Place"", - ""Region"", - ""Country"", - MIN(""Timestamp"") as ""FirstVisit"", - MAX(""Timestamp"") as ""LastVisit"", - COUNT(*)::integer as ""VisitCount"", - MAX(""Timestamp"") as ""MostRecentVisit"" - FROM ""Locations"" - WHERE ""UserId"" = {0} AND ""Place"" IS NOT NULL AND ""Place"" != '' - GROUP BY ""Place"", ""Region"", ""Country"" - ) - SELECT - cl.""Place"", - cl.""Region"", - cl.""Country"", - cl.""FirstVisit"", - cl.""LastVisit"", - cl.""VisitCount"", - ST_X(l.""Coordinates""::geometry) as ""RepLongitude"", - ST_Y(l.""Coordinates""::geometry) as ""RepLatitude"" - FROM CityLatest cl - INNER JOIN ""Locations"" l ON - l.""UserId"" = {0} AND - l.""Place"" = cl.""Place"" AND - COALESCE(l.""Region"", '') = COALESCE(cl.""Region"", '') AND - COALESCE(l.""Country"", '') = COALESCE(cl.""Country"", '') AND - l.""Timestamp"" = cl.""MostRecentVisit"" - ", userId).ToListAsync(); - + // The full partition aggregates all visits; DISTINCT ON selects exactly one coordinate row. + var cityGroupsSql = await _db.Database.SqlQuery(FormattableStringFactory.Create(scope + """ + + SELECT DISTINCT ON ("Country", "Region", "Place") "Country", "Region", "Place", + MIN("VisitTime") OVER membership AS "FirstVisit", + MAX("VisitTime") OVER membership AS "LastVisit", + (COUNT(*) OVER membership)::integer AS "VisitCount", + ST_X("Coordinates"::geometry) AS "RepLongitude", + ST_Y("Coordinates"::geometry) AS "RepLatitude" + FROM scoped WHERE "Place" <> '' + WINDOW membership AS (PARTITION BY "Country", "Region", "Place") + ORDER BY "Country", "Region", "Place", "VisitTime" DESC, "Id" DESC + """, parameters)).ToListAsync(); var cities = cityGroupsSql .Select(c => new CityVisitDetail { @@ -210,22 +161,19 @@ INNER JOIN ""Locations"" l ON VisitCount = c.VisitCount, Coordinates = new NetTopologySuite.Geometries.Point(c.RepLongitude, c.RepLatitude) { SRID = 4326 } }) - .OrderBy(c => c.CountryName) - .ThenBy(c => c.RegionName) - .ThenBy(c => c.Name) + .OrderBy(c => c.CountryName, StringComparer.Ordinal) + .ThenBy(c => c.RegionName, StringComparer.Ordinal) + .ThenBy(c => c.Name, StringComparer.Ordinal) .ToList(); - var fromDate = await userLocations.MinAsync(l => (DateTime?)l.Timestamp); - var toDate = await userLocations.MaxAsync(l => (DateTime?)l.Timestamp); - return new UserLocationStatsDetailedDto { TotalLocations = totalLocations, Countries = countries, Regions = regions, Cities = cities, - FromDate = fromDate, - ToDate = toDate + FromDate = summary.FromDate, + ToDate = summary.ToDate }; } @@ -271,145 +219,4 @@ private class CityGroupResult public double RepLatitude { get; set; } } - /// - /// Gets detailed statistics for a specific date range including country details, regions, and cities - /// - /// User ID - /// Start date (UTC) - /// End date (UTC) - /// Detailed statistics for the date range with arrays of country names, regions, and cities - public async Task GetDetailedStatsForDateRangeAsync(string userId, DateTime startDate, DateTime endDate) - { - var userLocations = _db.Locations.Where(l => l.UserId == userId - && l.LocalTimestamp >= startDate - && l.LocalTimestamp <= endDate); - - var totalLocations = await userLocations.CountAsync(); - - // OPTIMIZED: Get country details with coordinate averages calculated in database using PostGIS - var countryGroupsSql = await _db.Database.SqlQueryRaw(@" - SELECT - ""Country"", - MIN(""LocalTimestamp"") as ""FirstVisit"", - MAX(""LocalTimestamp"") as ""LastVisit"", - COUNT(*)::integer as ""VisitCount"", - AVG(ST_X(""Coordinates""::geometry)) as ""AvgLongitude"", - AVG(ST_Y(""Coordinates""::geometry)) as ""AvgLatitude"" - FROM ""Locations"" - WHERE ""UserId"" = {0} AND ""Country"" IS NOT NULL AND ""Country"" != '' - AND ""LocalTimestamp"" >= {1} AND ""LocalTimestamp"" <= {2} - GROUP BY ""Country"" - ", userId, startDate, endDate).ToListAsync(); - - // Detect home country: country with >40% of total visits or significantly more than average - var averageVisitCount = countryGroupsSql.Any() ? countryGroupsSql.Average(c => c.VisitCount) : 0; - var homeCountryThreshold = Math.Max(totalLocations * 0.4, averageVisitCount * 3); - - var countries = countryGroupsSql - .Select(c => new CountryVisitDetail - { - Name = c.Country ?? string.Empty, - FirstVisit = c.FirstVisit, - LastVisit = c.LastVisit, - VisitCount = c.VisitCount, - IsHomeCountry = c.VisitCount >= homeCountryThreshold, - Coordinates = new NetTopologySuite.Geometries.Point(c.AvgLongitude, c.AvgLatitude) { SRID = 4326 } - }) - .OrderByDescending(c => c.IsHomeCountry) - .ThenByDescending(c => c.VisitCount) - .ToList(); - - // OPTIMIZED: Get region details with coordinate averages calculated in database using PostGIS - var regionGroupsSql = await _db.Database.SqlQueryRaw(@" - SELECT - ""Region"", - ""Country"", - MIN(""LocalTimestamp"") as ""FirstVisit"", - MAX(""LocalTimestamp"") as ""LastVisit"", - COUNT(*)::integer as ""VisitCount"", - AVG(ST_X(""Coordinates""::geometry)) as ""AvgLongitude"", - AVG(ST_Y(""Coordinates""::geometry)) as ""AvgLatitude"" - FROM ""Locations"" - WHERE ""UserId"" = {0} AND ""Region"" IS NOT NULL AND ""Region"" != '' - AND ""LocalTimestamp"" >= {1} AND ""LocalTimestamp"" <= {2} - GROUP BY ""Region"", ""Country"" - ", userId, startDate, endDate).ToListAsync(); - - var regions = regionGroupsSql - .Select(r => new RegionVisitDetail - { - Name = r.Region ?? string.Empty, - CountryName = r.Country ?? string.Empty, - FirstVisit = r.FirstVisit, - LastVisit = r.LastVisit, - VisitCount = r.VisitCount, - Coordinates = new NetTopologySuite.Geometries.Point(r.AvgLongitude, r.AvgLatitude) { SRID = 4326 } - }) - .OrderBy(r => r.CountryName) - .ThenBy(r => r.Name) - .ToList(); - - // OPTIMIZED: Get city details with representative coordinate (from most recent visit) - var cityGroupsSql = await _db.Database.SqlQueryRaw(@" - WITH CityLatest AS ( - SELECT - ""Place"", - ""Region"", - ""Country"", - MIN(""LocalTimestamp"") as ""FirstVisit"", - MAX(""LocalTimestamp"") as ""LastVisit"", - COUNT(*)::integer as ""VisitCount"", - MAX(""LocalTimestamp"") as ""MostRecentVisit"" - FROM ""Locations"" - WHERE ""UserId"" = {0} AND ""Place"" IS NOT NULL AND ""Place"" != '' - AND ""LocalTimestamp"" >= {1} AND ""LocalTimestamp"" <= {2} - GROUP BY ""Place"", ""Region"", ""Country"" - ) - SELECT - cl.""Place"", - cl.""Region"", - cl.""Country"", - cl.""FirstVisit"", - cl.""LastVisit"", - cl.""VisitCount"", - ST_X(l.""Coordinates""::geometry) as ""RepLongitude"", - ST_Y(l.""Coordinates""::geometry) as ""RepLatitude"" - FROM CityLatest cl - INNER JOIN ""Locations"" l ON - l.""UserId"" = {0} AND - l.""Place"" = cl.""Place"" AND - COALESCE(l.""Region"", '') = COALESCE(cl.""Region"", '') AND - COALESCE(l.""Country"", '') = COALESCE(cl.""Country"", '') AND - l.""LocalTimestamp"" = cl.""MostRecentVisit"" - ", userId, startDate, endDate).ToListAsync(); - - var cities = cityGroupsSql - .Select(c => new CityVisitDetail - { - Name = c.Place ?? string.Empty, - RegionName = c.Region ?? string.Empty, - CountryName = c.Country ?? string.Empty, - FirstVisit = c.FirstVisit, - LastVisit = c.LastVisit, - VisitCount = c.VisitCount, - Coordinates = new NetTopologySuite.Geometries.Point(c.RepLongitude, c.RepLatitude) { SRID = 4326 } - }) - .OrderBy(c => c.CountryName) - .ThenBy(c => c.RegionName) - .ThenBy(c => c.Name) - .ToList(); - - var fromDate = await userLocations.MinAsync(l => (DateTime?)l.LocalTimestamp); - var toDate = await userLocations.MaxAsync(l => (DateTime?)l.LocalTimestamp); - - return new UserLocationStatsDetailedDto - { - TotalLocations = totalLocations, - Countries = countries, - Regions = regions, - Cities = cities, - FromDate = fromDate, - ToDate = toDate - }; - } } diff --git a/docs/06-Timeline.md b/docs/06-Timeline.md index 33d41081..40aa03f6 100644 --- a/docs/06-Timeline.md +++ b/docs/06-Timeline.md @@ -70,6 +70,52 @@ Location Search & Filters ![Timeline Statistics](images/private-timeline-statistics.JPG) +### Statistics grouping + +Statistics use recorded Country, Region and Place labels at read time. Only outer +ASCII space (U+0020) and U+0009–U+000D are trimmed; null and empty results are +missing. Case, accents, Unicode composition, internal whitespace and punctuation +remain significant. Countries group by country; regions by country and region; +settlements by country, region and place. Missing parents are separate from named +parents and are never inferred. Summary counts equal the corresponding detailed +group counts; Total Locations includes every record in the selected scope. + +The single geographic correction maps **East Macedonia and Thrace** to +**Eastern Macedonia and Thrace** only under the exact trimmed country **Greece**, +regardless of provider, manual entry or import origin. Other countries and +spellings remain unchanged. Sources checked 2026-09-05: + +- [European Commission demographic-observatory project](https://reforms-investments.ec.europa.eu/technical-support-instrument-0/labour-market-and-social-protection/supporting-greece-establish-demographic-observatory-through-evidence-based-tools_en) +- [Region of Eastern Macedonia and Thrace official website](https://www.pamth.gov.gr/en/) +- [European Commission JRC regional report](https://publications.jrc.ec.europa.eu/repository/handle/JRC100503) + +These sources support the English label variation, not the identity of individual +stored records. Parent scoping may increase visited counts; this region correction +may decrease them. The existing API contracts, including released Mobile counts, +are unchanged. Original labels, retained provider address lines, FullAddress and +feature metadata are not rewritten, and this correction adds no migration or +provider requests. + +Both User Timeline statistics views show children without recorded parents under +presentation-only **Country not recorded** and **Region not recorded** sections. +These sections do not add geographic entities or visited counts. Existing map links +still navigate to averaged country/region coordinates or one settlement visit. + +All-time visits use Timestamp; date windows use LocalTimestamp with inclusive +bounds. Visits and dates aggregate across corrected membership. Coordinate-average +inputs are ordered by Location ID. A settlement uses its latest relevant timestamp, +then highest Location ID to break ties. Countries sort by home status, visit count, +then ordinal name; regions and settlements sort by their ordinal parent/name tuples. +The home-country threshold remains the maximum of 40% of all records and three times +the mean recorded-country visit count. + +Statistics labels are not new search identifiers. Search, Bulk Edit Notes, +cascading choices, preview and update membership retain their existing semantics. +The string-only limitation remains: settlements with identical names and recorded +parents cannot be distinguished, and other alternate labels may still split one +entity. Historical Place/Region administrative ambiguity is not resolved. + + Bulk Edit Notes - From Locations > Bulk Edit Notes, you can search by filters and update notes for many records at once. diff --git a/tests/Wayfarer.Tests/Controllers/ApiLocationControllerTests.cs b/tests/Wayfarer.Tests/Controllers/ApiLocationControllerTests.cs index 8cd6bb59..d9902c22 100644 --- a/tests/Wayfarer.Tests/Controllers/ApiLocationControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/ApiLocationControllerTests.cs @@ -391,12 +391,14 @@ public async Task CheckIn_ReturnsForbid_WhenUserInactive() Assert.IsType(result); } + /// Statistics accept the authenticated principal without requiring a token header. [Fact] - public async Task GetStats_ReturnsUnauthorized_WhenNoToken() + public async Task GetStats_ReturnsStats_ForAuthenticatedPrincipalWithoutToken() { var db = CreateDbContext(); var user = SeedUserWithToken(db, "tok"); - var controller = BuildApiController(db, user, includeAuthHeader: false); + var controller = BuildApiController(db, user, includeAuthHeader: false, + statsService: new StubStatsService(new UserLocationStatsDto())); var result = await controller.GetStats(); diff --git a/tests/Wayfarer.Tests/Services/LocationStatsServicePostgresTests.cs b/tests/Wayfarer.Tests/Services/LocationStatsServicePostgresTests.cs index e73ea0b3..c7ba1107 100644 --- a/tests/Wayfarer.Tests/Services/LocationStatsServicePostgresTests.cs +++ b/tests/Wayfarer.Tests/Services/LocationStatsServicePostgresTests.cs @@ -1,4 +1,5 @@ using NetTopologySuite.Geometries; +using Microsoft.EntityFrameworkCore; using System.Text.Json; using Wayfarer.Models; using Wayfarer.Models.Dtos; @@ -35,6 +36,11 @@ public async Task PopulatedStatistics_ReturnExpectedAllTimeAndDateRangeDetails() jsonOptions.Converters.Add(new PointJsonConverter()); var json = JsonSerializer.Serialize(allTime, jsonOptions); using var document = JsonDocument.Parse(json); + Assert.Equal(new[] { "cities", "countries", "fromDate", "regions", "toDate", "totalLocations" }, + document.RootElement.EnumerateObject().Select(p => p.Name).Order(StringComparer.Ordinal)); + Assert.Equal(new[] { "coordinates", "countryName", "firstVisit", "lastVisit", "name", "regionName", "visitCount" }, + document.RootElement.GetProperty("cities")[0].EnumerateObject().Select(p => p.Name).Order(StringComparer.Ordinal)); + Assert.Equal(2, document.RootElement.GetProperty("totalLocations").GetInt32()); Assert.Equal(25.87, document.RootElement.GetProperty("countries")[0].GetProperty("coordinates").GetProperty("longitude").GetDouble(), precision: 6); @@ -62,6 +68,127 @@ public async Task PartialAddressHierarchy_RemainsReadable() AssertPartialHierarchy(dateRange); } + /// One dataset proves corrected membership, scope, representatives and read-only behavior. + [PostgresFact] + public async Task CorrectedGroups_AgreeAcrossScopes_AndPreserveStoredRows() + { + var user = await fixture.CreateUserAsync(); + var other = await fixture.CreateUserAsync(); + var start = new DateTime(2026, 9, 3, 8, 0, 0, DateTimeKind.Utc); + await using var context = fixture.CreateContext(); + var labels = new (string? Country, string? Region, string? Place)[] + { + (" Greece ", " East Macedonia and Thrace\t", " Port "), + ("Greece", "Eastern Macedonia and Thrace", "Port"), + ("Other", "East Macedonia and Thrace", "Port"), + ("Other", "Eastern Macedonia and Thrace", "Port"), + (null, "East Macedonia and Thrace", "Port"), + ("", null, "Port"), + (" \t", "\r\n", "Port"), + ("Greece", null, "Port"), + ("Greece", "Another region", "Port"), + (null, null, null) + }; + var rows = labels.Select((label, index) => + { + var row = Location(user.Id, start, 20 + index, 40); + (row.Country, row.Region, row.Place) = label; + row.LocalTimestamp = index == 8 ? start.AddDays(1) : start.AddMinutes(index % 2); + row.FullAddress = $"Original {index}"; + row.ProviderAddressLine1 = $"Provider {index}"; + return row; + }).ToArray(); + context.Locations.AddRange(rows); + context.Locations.Add(Location(other.Id, start, 99, 40)); + await context.SaveChangesAsync(); + var before = await context.Locations.AsNoTracking().Where(l => l.UserId == user.Id) + .OrderBy(l => l.Id).ToListAsync(); + var service = new LocationStatsService(context); + + var all = await service.GetDetailedStatsForUserAsync(user.Id); + var summary = await service.GetStatsForUserAsync(user.Id); + var window = await service.GetDetailedStatsForDateRangeAsync(user.Id, start, start.AddMinutes(1)); + var windowSummary = await service.GetStatsForDateRangeAsync(user.Id, start, start.AddMinutes(1)); + AssertAgreement(summary, all, 10, 2, 5, 7); + AssertAgreement(windowSummary, window, 9, 2, 4, 6); + foreach (var detail in new[] { all, window }) + { + var region = Assert.Single(detail.Regions, r => r.CountryName == "Greece" && + r.Name == "Eastern Macedonia and Thrace"); + Assert.Equal(2, region.VisitCount); + Assert.Equal(20.5, region.Coordinates!.X); + var city = Assert.Single(detail.Cities, c => c.CountryName == "Greece" && + c.RegionName == region.Name && c.Name == "Port"); + Assert.Equal(2, city.VisitCount); + Assert.Equal(21, city.Coordinates!.X); + Assert.Equal(2, Assert.Single(detail.Cities, c => c.CountryName == "" && c.RegionName == "").VisitCount); + } + Assert.Equal(26, Assert.Single(all.Cities, c => c.CountryName == "" && c.RegionName == "").Coordinates!.X); + Assert.Equal(25, Assert.Single(window.Cities, c => c.CountryName == "" && c.RegionName == "").Coordinates!.X); + Assert.Equal(new[] { "Greece", "Other" }, all.Countries.Select(c => c.Name)); + Assert.Equal(new[] { "", "Greece", "Greece", "Other", "Other" }, all.Regions.Select(r => r.CountryName)); + Assert.Equal(start, all.ToDate); + Assert.Equal(start.AddMinutes(1), window.ToDate); + Assert.Equal(1, (await service.GetStatsForUserAsync(other.Id)).TotalLocations); + var after = await context.Locations.AsNoTracking().Where(l => l.UserId == user.Id) + .OrderBy(l => l.Id).ToListAsync(); + // Compare every mapped scalar, including retained provider/feature fields, from fresh database reads. + var properties = context.Model.FindEntityType(typeof(Location))!.GetProperties(); + foreach (var property in properties.Where(p => p.PropertyInfo != null)) + Assert.Equal(before.Select(row => property.PropertyInfo!.GetValue(row)), + after.Select(row => property.PropertyInfo!.GetValue(row))); + } + + /// Exercises the exact trim boundary and preserves non-ASCII, case and composition distinctions. + [PostgresTheory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" \t\n\v\f\r", "")] + [InlineData(" \t\n\v\f\rA \t B-'é\r\f\v\n\t ", "A \t B-'é")] + [InlineData("\u0085A\u0085", "\u0085A\u0085")] + [InlineData("\u00a0A\u00a0", "\u00a0A\u00a0")] + [InlineData("\u2003A\u2003", "\u2003A\u2003")] + [InlineData("\u001fA\u001f", "\u001fA\u001f")] + [InlineData("é", "é")] + [InlineData("e\u0301", "e\u0301")] + [InlineData("a", "a")] + public async Task Normalization_PreservesEveryCharacterOutsideOuterAsciiWhitespace(string? value, string expected) + { + var user = await fixture.CreateUserAsync(); + await using var context = fixture.CreateContext(); + var timestamp = new DateTime(2026, 9, 3, 8, 0, 0, DateTimeKind.Utc); + var row = Location(user.Id, timestamp, 20, 40); + (row.Country, row.Region, row.Place) = (value, value, value); + context.Locations.Add(row); + // The comparison row catches accidental Unicode/case folding and broader whitespace trimming. + var comparison = Location(user.Id, timestamp, 21, 40); + (comparison.Country, comparison.Region, comparison.Place) = ("A", "A", "A"); + context.Locations.Add(comparison); + await context.SaveChangesAsync(); + var service = new LocationStatsService(context); + var detail = await service.GetDetailedStatsForUserAsync(user.Id); + var count = expected == "" ? 1 : 2; + AssertAgreement(await service.GetStatsForUserAsync(user.Id), detail, 2, count, count, count); + Assert.Equal(expected == "" ? new[] { "A" } : new[] { "A", expected }.Order(StringComparer.Ordinal), + detail.Countries.Select(c => c.Name)); + if (expected != "") + { + Assert.Contains(detail.Regions, r => r.Name == expected && r.CountryName == expected); + Assert.Contains(detail.Cities, c => c.Name == expected && c.CountryName == expected && c.RegionName == expected); + } + } + + /// Summary counts describe exactly the detailed arrays, without synthetic parents. + private static void AssertAgreement(UserLocationStatsDto summary, UserLocationStatsDetailedDto detail, + int locations, int countries, int regions, int cities) + { + Assert.Equal((locations, countries, regions, cities), + (summary.TotalLocations, summary.CountriesVisited, summary.RegionsVisited, summary.CitiesVisited)); + Assert.Equal((locations, countries, regions, cities), + (detail.TotalLocations, detail.Countries.Count, detail.Regions.Count, detail.Cities.Count)); + Assert.Equal((summary.FromDate, summary.ToDate), (detail.FromDate, detail.ToDate)); + } + private static Location Location(string userId, DateTime timestamp, double longitude, double latitude) => new() { UserId = userId, diff --git a/tests/Wayfarer.Tests/Services/LocationStatsServiceTests.cs b/tests/Wayfarer.Tests/Services/LocationStatsServiceTests.cs index 6c967ee1..9c9d83fc 100644 --- a/tests/Wayfarer.Tests/Services/LocationStatsServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/LocationStatsServiceTests.cs @@ -6,22 +6,18 @@ namespace Wayfarer.Tests.Services; -/// -/// Tests for covering basic statistics calculations. -/// Note: Detailed stats tests (GetDetailedStatsForUserAsync, GetDetailedStatsForDateRangeAsync) -/// are skipped because they use raw SQL with PostGIS functions that require PostgreSQL. -/// -public class LocationStatsServiceTests : TestBase +/// Preserves summary and date-boundary contracts against the PostgreSQL statistics projection. +[Collection(PostgresImportTestCollection.Name)] +public class LocationStatsServiceTests(PostgresImportTestFixture fixture) { #region GetStatsForUserAsync Tests - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_ReturnsZeroStats_ForUserWithNoLocations() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); await db.SaveChangesAsync(); var service = new LocationStatsService(db); @@ -38,13 +34,12 @@ public async Task GetStatsForUserAsync_ReturnsZeroStats_ForUserWithNoLocations() Assert.Null(result.ToDate); } - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_CountsAllLocations() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var locations = new[] { @@ -64,13 +59,12 @@ public async Task GetStatsForUserAsync_CountsAllLocations() Assert.Equal(3, result.TotalLocations); } - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_CountsDistinctCountries() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var locations = new[] { @@ -91,13 +85,12 @@ public async Task GetStatsForUserAsync_CountsDistinctCountries() Assert.Equal(3, result.CountriesVisited); // USA, France, Germany } - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_CountsDistinctCities() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var locations = new[] { @@ -118,13 +111,12 @@ public async Task GetStatsForUserAsync_CountsDistinctCities() Assert.Equal(3, result.CitiesVisited); // New York, Los Angeles, Paris } - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_CountsDistinctRegions() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var locations = new[] { @@ -145,13 +137,12 @@ public async Task GetStatsForUserAsync_CountsDistinctRegions() Assert.Equal(3, result.RegionsVisited); // NY, CA, Île-de-France } - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_ReturnsCorrectDateRange() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var oldestDate = DateTime.UtcNow.AddDays(-30); var newestDate = DateTime.UtcNow; @@ -177,13 +168,12 @@ public async Task GetStatsForUserAsync_ReturnsCorrectDateRange() Assert.Equal(newestDate, result.ToDate.Value, TimeSpan.FromSeconds(1)); } - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_IgnoresNullCountries() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var locations = new[] { @@ -203,13 +193,12 @@ public async Task GetStatsForUserAsync_IgnoresNullCountries() Assert.Equal(1, result.CountriesVisited); // only USA } - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_IgnoresEmptyStrings() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var locations = new[] { @@ -231,14 +220,13 @@ public async Task GetStatsForUserAsync_IgnoresEmptyStrings() Assert.Equal(1, result.RegionsVisited); } - [Fact] + [PostgresFact] public async Task GetStatsForUserAsync_OnlyCountsUserOwnLocations() { // Arrange - var db = CreateDbContext(); - var user1 = TestDataFixtures.CreateUser(); - var user2 = TestDataFixtures.CreateUser(); - db.Users.AddRange(user1, user2); + await using var db = fixture.CreateContext(); + var user1 = await fixture.CreateUserAsync(); + var user2 = await fixture.CreateUserAsync(); var locations = new[] { @@ -262,13 +250,12 @@ public async Task GetStatsForUserAsync_OnlyCountsUserOwnLocations() #region GetStatsForDateRangeAsync Tests - [Fact] + [PostgresFact] public async Task GetStatsForDateRangeAsync_FiltersToDateRange() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var startDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = new DateTime(2024, 1, 31, 23, 59, 59, DateTimeKind.Utc); @@ -293,13 +280,12 @@ public async Task GetStatsForDateRangeAsync_FiltersToDateRange() Assert.Equal(2, result.CountriesVisited); // USA, France } - [Fact] + [PostgresFact] public async Task GetStatsForDateRangeAsync_ReturnsZeroForEmptyRange() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var locations = new[] { @@ -324,13 +310,12 @@ public async Task GetStatsForDateRangeAsync_ReturnsZeroForEmptyRange() Assert.Null(result.ToDate); } - [Fact] + [PostgresFact] public async Task GetStatsForDateRangeAsync_IncludesEdgeDates() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var startDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = new DateTime(2024, 1, 31, 23, 59, 59, DateTimeKind.Utc); @@ -352,13 +337,12 @@ public async Task GetStatsForDateRangeAsync_IncludesEdgeDates() Assert.Equal(2, result.TotalLocations); } - [Fact] + [PostgresFact] public async Task GetStatsForDateRangeAsync_UsesLocalTimestamp() { // Arrange - var db = CreateDbContext(); - var user = TestDataFixtures.CreateUser(); - db.Users.Add(user); + await using var db = fixture.CreateContext(); + var user = await fixture.CreateUserAsync(); var startDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = new DateTime(2024, 1, 31, 23, 59, 59, DateTimeKind.Utc); diff --git a/tests/client/timelineDetailedStatistics.test.mjs b/tests/client/timelineDetailedStatistics.test.mjs index 7e302650..c6fe6ca3 100644 --- a/tests/client/timelineDetailedStatistics.test.mjs +++ b/tests/client/timelineDetailedStatistics.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; +import vm from 'node:vm'; +import { renderStatistics } from '../../wwwroot/js/Areas/User/Timeline/statistics.js'; const timelineScripts = [ 'wwwroot/js/Areas/User/Timeline/Index.js', @@ -17,3 +19,56 @@ test('both Timeline views use the Wayfarer alert for detailed-statistics failure assert.doesNotMatch(source, /Error \$\{response\.status\}: \$\{await response\.text\(\)\}/); } }); + +// Execute the production renderer in isolation from map/network bootstrapping. +const render = async (path, stats) => { + const source = await readFile(path, 'utf8'); + const start = source.indexOf('const generateStatsModalContent ='); + const end = source.indexOf('\n};', start) + 3; + return vm.runInNewContext(`${source.slice(start, end)}; generateStatsModalContent(stats, 'countries')`, { + stats, renderStatistics, viewerTimeZone: 'UTC', formatDate: () => 'date', + formatDateDisplay: () => 'period', currentDate: new Date(), currentViewType: 'day' + }); +}; + +test('both production renderers place missing parents once and encode geographic labels', async () => { + const detail = { visitCount: 1, coordinates: { latitude: 40, longitude: 25 } }; + const stats = { + totalLocations: 4, + countries: [{ ...detail, name: '' }], + regions: [ + { ...detail, name: '', countryName: '' }, + { ...detail, name: 'orphan-region', countryName: '' } + ], + cities: [ + { ...detail, name: '', countryName: '', regionName: '' }, + { ...detail, name: 'country-only-city', countryName: '', regionName: '' }, + { ...detail, name: 'region-only-city', countryName: '', regionName: 'orphan-region' }, + { ...detail, name: 'parentless-city', countryName: '', regionName: '' } + ] + }; + const original = JSON.stringify(stats); + for (const path of timelineScripts) { + const html = await render(path, stats); + assert.ok(html.includes('Country not recorded'), path); + assert.equal(html.split('Region not recorded').length - 1, 2, path); + for (const label of ['<country>', '<region>', 'orphan-region', + '<img src=x onerror="boom">', 'country-only-city', 'region-only-city', 'parentless-city']) { + assert.equal(html.split(label).length - 1, 1, `${path}: ${label}`); + } + const recordedCountry = html.slice(html.indexOf('id="country-heading-0"'), html.indexOf('id="country-heading-1"')); + const missingCountry = html.slice(html.indexOf('id="country-heading-1"')); + assert.ok(recordedCountry.includes('country-only-city') && !recordedCountry.includes('parentless-city'), path); + assert.ok(missingCountry.includes('orphan-region') && missingCountry.includes('region-only-city'), path); + const missingRegion = missingCountry.slice(missingCountry.indexOf('Region not recorded')); + assert.ok(missingRegion.includes('parentless-city') && !missingRegion.includes('region-only-city'), path); + assert.ok(!html.includes(' { * @returns {string} HTML content */ const generateStatsModalContent = (stats, highlightType) => { - let html = '
'; - - // Summary section - html += '
'; - html += '
'; - html += `
Overview
`; - html += `

Total Locations: ${stats.totalLocations}

`; - html += `

Period: ${formatDateDisplay(currentDate, currentViewType)}

`; - if (stats.fromDate && stats.toDate) { - html += `

Date Range: ${formatDate({ iso: stats.fromDate, displayTimeZone: viewerTimeZone })} to ${formatDate({ iso: stats.toDate, displayTimeZone: viewerTimeZone })}

`; - } - html += '
'; - html += '
'; - - // Countries section with hierarchical collapsible structure - const countriesHighlight = highlightType === 'countries' ? 'bg-light border' : ''; - html += `
`; - html += '
'; - html += `
Countries (${stats.countries.length})
`; - - if (stats.countries.length > 0) { - html += '
'; - - stats.countries.forEach((country, countryIdx) => { - const homeLabel = country.isHomeCountry ? ' Home' : ''; - const firstVisit = formatDate({ iso: country.firstVisit, displayTimeZone: viewerTimeZone }); - const lastVisit = formatDate({ iso: country.lastVisit, displayTimeZone: viewerTimeZone }); - - // Extract coordinates from PostGIS Point - const lat = country.coordinates?.latitude || 0; - const lng = country.coordinates?.longitude || 0; - const countryMapUrl = `?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}&zoom=8`; - - // Get regions for this country - const countryRegions = stats.regions.filter(r => r.countryName === country.name); - - html += `
`; - html += `

`; - html += `
`; - html += ``; - html += ` Map`; - html += `
`; - html += `

`; - html += `
`; - html += `
`; - - if (countryRegions.length > 0) { - html += `
Regions (${countryRegions.length})
`; - html += `
`; - - countryRegions.forEach((region, regionIdx) => { - const regFirstVisit = formatDate({ iso: region.firstVisit, displayTimeZone: viewerTimeZone }); - const regLastVisit = formatDate({ iso: region.lastVisit, displayTimeZone: viewerTimeZone }); - const regLat = region.coordinates?.latitude || 0; - const regLng = region.coordinates?.longitude || 0; - const regionMapUrl = `?lat=${regLat.toFixed(6)}&lng=${regLng.toFixed(6)}&zoom=10`; - - // Get cities for this region - const regionCities = stats.cities.filter(c => c.regionName === region.name && c.countryName === country.name); - - html += `
`; - html += `

`; - html += `
`; - html += ``; - html += ` Map`; - html += `
`; - html += `

`; - html += `
`; - html += `
`; - - if (regionCities.length > 0) { - html += `
Cities (${regionCities.length})
`; - html += '
'; - - regionCities.forEach(city => { - const cityFirstVisit = formatDate({ iso: city.firstVisit, displayTimeZone: viewerTimeZone }); - const cityLastVisit = formatDate({ iso: city.lastVisit, displayTimeZone: viewerTimeZone }); - const cityLat = city.coordinates?.latitude || 0; - const cityLng = city.coordinates?.longitude || 0; - const cityMapUrl = `?lat=${cityLat.toFixed(6)}&lng=${cityLng.toFixed(6)}&zoom=13`; - - html += `
`; - html += `
${city.name} (${city.visitCount} records, ${cityFirstVisit} - ${cityLastVisit})
`; - html += ` Map`; - html += `
`; - }); - - html += '
'; - } else { - html += '

No cities in this region

'; - } - - html += `
`; - }); - - html += `
`; - } else { - html += '

No regions in this country

'; - } - - html += `
`; - }); - - html += '
'; - } else { - html += '

No country data available

'; - } - - html += '
'; - html += '
'; - - html += '
'; - - return html; + return renderStatistics(stats, highlightType, + iso => formatDate({ iso, displayTimeZone: viewerTimeZone }), formatDateDisplay(currentDate, currentViewType)); }; /** diff --git a/wwwroot/js/Areas/User/Timeline/Index.js b/wwwroot/js/Areas/User/Timeline/Index.js index 18446baf..d66c36c1 100644 --- a/wwwroot/js/Areas/User/Timeline/Index.js +++ b/wwwroot/js/Areas/User/Timeline/Index.js @@ -1,3 +1,4 @@ +import { renderStatistics } from './statistics.js'; let locations = []; // Declare locations as a global variable let mapContainer = null; let zoomLevel = 3; @@ -644,123 +645,8 @@ const showDetailedStats = async (statType) => { * @returns {string} HTML content */ const generateStatsModalContent = (stats, highlightType) => { - let html = '
'; - - // Summary section - html += '
'; - html += '
'; - html += `
Overview
`; - html += `

Total Locations: ${stats.totalLocations}

`; - if (stats.fromDate && stats.toDate) { - html += `

Date Range: ${formatDate({ iso: stats.fromDate, displayTimeZone: viewerTimeZone })} to ${formatDate({ iso: stats.toDate, displayTimeZone: viewerTimeZone })}

`; - } - html += '
'; - html += '
'; - - // Countries section with hierarchical collapsible structure - const countriesHighlight = highlightType === 'countries' ? 'bg-light border' : ''; - html += `
`; - html += '
'; - html += `
Countries (${stats.countries.length})
`; - - if (stats.countries.length > 0) { - html += '
'; - - stats.countries.forEach((country, countryIdx) => { - const homeLabel = country.isHomeCountry ? ' Home' : ''; - const firstVisit = formatDate({ iso: country.firstVisit, displayTimeZone: viewerTimeZone }); - const lastVisit = formatDate({ iso: country.lastVisit, displayTimeZone: viewerTimeZone }); - - // Extract coordinates from PostGIS Point - const lat = country.coordinates?.latitude || 0; - const lng = country.coordinates?.longitude || 0; - const countryMapUrl = `?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}&zoom=8`; - - // Get regions for this country - const countryRegions = stats.regions.filter(r => r.countryName === country.name); - - html += `
`; - html += `

`; - html += `
`; - html += ``; - html += ` Map`; - html += `
`; - html += `

`; - html += `
`; - html += `
`; - - if (countryRegions.length > 0) { - html += `
Regions (${countryRegions.length})
`; - html += `
`; - - countryRegions.forEach((region, regionIdx) => { - const regFirstVisit = formatDate({ iso: region.firstVisit, displayTimeZone: viewerTimeZone }); - const regLastVisit = formatDate({ iso: region.lastVisit, displayTimeZone: viewerTimeZone }); - const regLat = region.coordinates?.latitude || 0; - const regLng = region.coordinates?.longitude || 0; - const regionMapUrl = `?lat=${regLat.toFixed(6)}&lng=${regLng.toFixed(6)}&zoom=10`; - - // Get cities for this region - const regionCities = stats.cities.filter(c => c.regionName === region.name && c.countryName === country.name); - - html += `
`; - html += `

`; - html += `
`; - html += ``; - html += ` Map`; - html += `
`; - html += `

`; - html += `
`; - html += `
`; - - if (regionCities.length > 0) { - html += `
Cities (${regionCities.length})
`; - html += '
'; - - regionCities.forEach(city => { - const cityFirstVisit = formatDate({ iso: city.firstVisit, displayTimeZone: viewerTimeZone }); - const cityLastVisit = formatDate({ iso: city.lastVisit, displayTimeZone: viewerTimeZone }); - const cityLat = city.coordinates?.latitude || 0; - const cityLng = city.coordinates?.longitude || 0; - const cityMapUrl = `?lat=${cityLat.toFixed(6)}&lng=${cityLng.toFixed(6)}&zoom=13`; - - html += `
`; - html += `
${city.name} (${city.visitCount} records, ${cityFirstVisit} - ${cityLastVisit})
`; - html += ` Map`; - html += `
`; - }); - - html += '
'; - } else { - html += '

No cities in this region

'; - } - - html += `
`; - }); - - html += `
`; - } else { - html += '

No regions in this country

'; - } - - html += `
`; - }); - - html += '
'; - } else { - html += '

No country data available

'; - } - - html += '
'; - html += '
'; - - html += '
'; - - return html; + return renderStatistics(stats, highlightType, + iso => formatDate({ iso, displayTimeZone: viewerTimeZone })); }; const handleStream = (event) => { diff --git a/wwwroot/js/Areas/User/Timeline/statistics.js b/wwwroot/js/Areas/User/Timeline/statistics.js new file mode 100644 index 00000000..93cda6b3 --- /dev/null +++ b/wwwroot/js/Areas/User/Timeline/statistics.js @@ -0,0 +1,139 @@ +/** + * Render the shared Timeline statistics hierarchy with presentation-only missing parents. + * Coordinate URLs, link hooks, accordion IDs and recorded counts retain their existing behavior. + */ +export const renderStatistics = (stats, highlightType, displayDate, period = null) => { + // Labels are encoded only at the HTML boundary; raw component names remain matching keys. + const encode = (value) => String(value).replace(/[&<>"']/g, char => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[char]); + // Missing-parent sections exist only in this local presentation tree, never in API arrays. + const countries = [...stats.countries]; + if (stats.regions.some(r => r.countryName === '') || stats.cities.some(c => c.countryName === '')) { + countries.push({ name: '', missing: true }); + } + let html = '
'; + + // Summary section + html += '
'; + html += '
'; + html += `
Overview
`; + html += `

Total Locations: ${stats.totalLocations}

`; + if (period !== null) html += `

Period: ${encode(period)}

`; + if (stats.fromDate && stats.toDate) { + html += `

Date Range: ${displayDate(stats.fromDate)} to ${displayDate(stats.toDate)}

`; + } + html += '
'; + html += '
'; + + // Countries section with hierarchical collapsible structure + const countriesHighlight = highlightType === 'countries' ? 'bg-light border' : ''; + html += `
`; + html += '
'; + html += `
Countries (${stats.countries.length})
`; + + if (countries.length > 0) { + html += '
'; + + countries.forEach((country, countryIdx) => { + const homeLabel = country.isHomeCountry ? ' Home' : ''; + const firstVisit = country.missing ? '' : displayDate(country.firstVisit); + const lastVisit = country.missing ? '' : displayDate(country.lastVisit); + + // Extract coordinates from PostGIS Point + const lat = country.coordinates?.latitude || 0; + const lng = country.coordinates?.longitude || 0; + const countryMapUrl = `?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}&zoom=8`; + + // Get regions for this country + const countryRegions = stats.regions.filter(r => r.countryName === country.name); + const recordedRegionCount = countryRegions.length; + if (stats.cities.some(c => c.countryName === country.name && c.regionName === '')) { + countryRegions.push({ name: '', missing: true }); + } + + html += `
`; + html += `

`; + html += `
`; + html += ``; + if (!country.missing) html += ` Map`; + html += `
`; + html += `

`; + html += `
`; + html += `
`; + + if (countryRegions.length > 0) { + html += `
Regions (${recordedRegionCount})
`; + html += `
`; + + countryRegions.forEach((region, regionIdx) => { + const regFirstVisit = region.missing ? '' : displayDate(region.firstVisit); + const regLastVisit = region.missing ? '' : displayDate(region.lastVisit); + const regLat = region.coordinates?.latitude || 0; + const regLng = region.coordinates?.longitude || 0; + const regionMapUrl = `?lat=${regLat.toFixed(6)}&lng=${regLng.toFixed(6)}&zoom=10`; + + // Get cities for this region + const regionCities = stats.cities.filter(c => c.regionName === region.name && c.countryName === country.name); + + html += `
`; + html += `

`; + html += `
`; + html += ``; + if (!region.missing) html += ` Map`; + html += `
`; + html += `

`; + html += `
`; + html += `
`; + + if (regionCities.length > 0) { + html += `
Cities (${regionCities.length})
`; + html += '
'; + + regionCities.forEach(city => { + const cityFirstVisit = displayDate(city.firstVisit); + const cityLastVisit = displayDate(city.lastVisit); + const cityLat = city.coordinates?.latitude || 0; + const cityLng = city.coordinates?.longitude || 0; + const cityMapUrl = `?lat=${cityLat.toFixed(6)}&lng=${cityLng.toFixed(6)}&zoom=13`; + + html += `
`; + html += `
${encode(city.name)} (${city.visitCount} records, ${cityFirstVisit} - ${cityLastVisit})
`; + html += ` Map`; + html += `
`; + }); + + html += '
'; + } else { + html += '

No cities in this region

'; + } + + html += `
`; + }); + + html += `
`; + } else { + html += '

No regions in this country

'; + } + + html += `
`; + }); + + html += '
'; + } else { + html += '

No country data available

'; + } + + html += '
'; + html += '
'; + + html += '
'; + + return html; +};