Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ and the [master plan](#master-plan) below it says what does not yet.
request a second and to the website's own addresses. Settings says how many posts it found, how
many say when they were published, and in plain words anything that got in the way. Insights shows
them: how many there are, the newest ten with how often each was read, the most read ever, and a
calendar of the last twelve months of posting. How it behaves, and how to switch it off, is under
[Reading a website's sitemap](#reading-a-websites-sitemap).
calendar of the last twelve months of posting. Each post opens on a page of its own: how often it
has been read since its views were first counted, on average per day and in its best month, a chart
of its views over the period you choose by the hour, day, week, month or year, every month and year
by total views or by average per day, and its last thirteen weeks day by day. Every post is listed
on a screen of its own, searchable by title or address, the most read or the newest first. How it
behaves, and how to switch it off, is under [Reading a website's sitemap](#reading-a-websites-sitemap).
- Where your readers are, by country, by town, and by the network they arrived over, ranked by how
many people were in each place rather than by how much browsing they did. Towns are named as an
estimate, because that is what they are; the network view is there because a hundred rented
Expand Down Expand Up @@ -459,6 +463,11 @@ up. Each line is marked with one of four states. Last reviewed 2026-11-07.
and hour, every month and year by total views or by average per day, and the same for people only.
- **Done** · Posts from the website's own sitemap: how many, the newest ten with their views, the most
read ever, and a calendar of the last 12 months of posting.
- **Done** · A page for each post: its views since they were first counted, on average per day and in
its best month, a chart of its views by the hour, day, week, month or year, every month and year by
total views or by average per day, and its last 13 weeks day by day, for everybody or people only.
- **Done** · Every post in one list, with how often each was read: searchable by title or address, the
most read or the newest first, a page at a time.

### Phase 6 — AI analytics

Expand Down Expand Up @@ -588,9 +597,9 @@ up. Each line is marked with one of four states. Last reviewed 2026-11-07.

**8.4 Live**

- **Done** · Visitors now, pages being read, a trail per visitor. Only confirmed or self-declared
machinery is named while a visit is under way — by design, so a person is never guessed at
mid-read.
- **Done** · Visitors now and the page views of the same half hour, pages being read, a trail per
visitor. Only confirmed or self-declared machinery is named while a visit is under way — by
design, so a person is never guessed at mid-read.
- **Done** · Each row says on its first line how the visitor arrived: the site that sent them,
"Came straight here", or "Arrived earlier" where the half hour did not see them arrive.
- **Done** · A page left open keeps nobody on the list: a visitor is shown while a page they opened
Expand Down
107 changes: 107 additions & 0 deletions backend/src/Dewiride.Analytics.Api/Analytics/QueryVocabulary.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Frozen;
using Dewiride.Analytics.Application.Analytics;
using Dewiride.Analytics.Application.Posts;
using Dewiride.Analytics.Application.Sessions;
using Dewiride.Analytics.Application.Tenancy;
using Dewiride.Analytics.Classification;
Expand Down Expand Up @@ -33,6 +34,15 @@ internal static class QueryVocabulary
/// <summary>What is said when a series is asked for a measure this product does not count.</summary>
public const string UnknownMetric = "Ask for either page views or visitors.";

/// <summary>What is said when a question about one post names no post.</summary>
public const string NoPost = "Ask about a post by the path Insights lists it at.";

/// <summary>What is said when a website's posts are asked for in an order they are not listed in.</summary>
public const string UnknownPostsOrder = "List the posts by the most read or by the newest.";

/// <summary>What is said when a website's posts are searched for more than a search may hold.</summary>
public static readonly string SearchTooLong = $"Search for at most {PostSearch.MostCharacters} characters.";

/// <summary>Which measure a series counts, by the word used on the wire.</summary>
public static readonly FrozenDictionary<string, TimeSeriesMetric> Metrics =
new Dictionary<string, TimeSeriesMetric>(StringComparer.OrdinalIgnoreCase)
Expand Down Expand Up @@ -100,6 +110,19 @@ internal static class QueryVocabulary
public static readonly FrozenDictionary<KeptGranularity, string> KeptGranularityNames =
KeptGranularities.ToFrozenDictionary(entry => entry.Value, entry => entry.Key);

/// <summary>
/// The orders a website's posts may be listed in, by the word used on the wire.
/// </summary>
/// <remarks>
/// The most read is what a list means when no order is named.
/// </remarks>
public static readonly FrozenDictionary<string, PostsOrder> PostsOrders =
new Dictionary<string, PostsOrder>(StringComparer.OrdinalIgnoreCase)
{
["most-read"] = PostsOrder.MostRead,
["newest"] = PostsOrder.Newest,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Reads who a question is asked about.
/// </summary>
Expand All @@ -123,6 +146,75 @@ public static bool TryReadPopulation(string? asked, out Population population)
return Populations.TryGetValue(asked, out population);
}

/// <summary>
/// Reads the order a website's posts are asked for in.
/// </summary>
/// <remarks>
/// Nothing named is the most read. Called before the site is resolved, like every other check, so a
/// word naming no order is refused identically whether or not the site exists.
/// </remarks>
/// <param name="asked">What the caller supplied, or nothing.</param>
/// <param name="order">The order it names, where it names one posts are listed in.</param>
/// <returns><see langword="true"/> when the request can be answered as asked.</returns>
public static bool TryReadPostsOrder(string? asked, out PostsOrder order)
{
if (string.IsNullOrEmpty(asked))
{
order = PostsOrder.MostRead;

return true;
}

return PostsOrders.TryGetValue(asked, out order);
}

/// <summary>
/// Reads how much of a list a question asks for, and from where along it.
/// </summary>
/// <remarks>
/// Every list on the dashboard is read a page at a time under identical rules, so they are written here
/// once. Called before the site is resolved, so a malformed request is refused identically whether or not
/// the site exists: checking in the other order would turn a bad limit into a way of finding out which
/// identifiers on an install are real.
/// </remarks>
/// <param name="limit">How many rows the caller asked for, or nothing to take the list's own default.</param>
/// <param name="offset">How many leading rows to pass over, or nothing for none.</param>
/// <param name="bounds">What this particular list allows.</param>
/// <param name="paging">The checked request, where it was usable.</param>
/// <param name="refusal">Why it was not, where it was not.</param>
/// <returns><see langword="true"/> when the request can be answered as asked.</returns>
public static bool TryReadPaging(
int? limit,
int? offset,
ListBounds bounds,
out Paging paging,
out string? refusal)
{
paging = default;

var rows = limit ?? bounds.Fallback;
var passed = offset ?? 0;

if (rows < 1 || rows > bounds.Most)
{
refusal = $"Ask for between 1 and {bounds.Most} {bounds.Rows} at a time.";

return false;
}

if (passed < 0)
{
refusal = "Start the list at the beginning or further along it, never before it.";

return false;
}

paging = new Paging(rows, passed);
refusal = null;

return true;
}

/// <summary>
/// Refuses a question that cannot be answered as asked.
/// </summary>
Expand Down Expand Up @@ -177,4 +269,19 @@ public static async Task<DateTimeOffset> JudgedUpToAsync(

return JudgingReach.Settled(caughtUp, clock.GetUtcNow(), settings.IdleTimeout);
}

/// <summary>
/// What one list allows, and what its rows are called when a request for them is refused.
/// </summary>
/// <param name="Fallback">How many rows to return when the caller does not say.</param>
/// <param name="Most">How many rows one request may ask for.</param>
/// <param name="Rows">What the rows are called, for the sentence a refusal answers with.</param>
internal readonly record struct ListBounds(int Fallback, int Most, string Rows);

/// <summary>
/// A checked request for part of a list: how much of it, and from where along it.
/// </summary>
/// <param name="Limit">How many rows to return.</param>
/// <param name="Offset">How many leading rows to pass over.</param>
internal readonly record struct Paging(int Limit, int Offset);
}
87 changes: 83 additions & 4 deletions backend/src/Dewiride.Analytics.Api/Contracts/AnalyticsContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,8 +1034,8 @@ public sealed record InsightsBestDay(string Day, long PageViews);
/// <param name="Month">The month, as <c>yyyy-MM</c> on the website's calendar.</param>
/// <param name="PageViews">Pages delivered in it, or nothing where it cannot be said.</param>
/// <param name="DaysMeasured">
/// How many of its days those pages cover: from the website's first day, and up to and including today
/// in the month running now. None where the month has no figure.
/// How many of its days those pages cover: from the website's first day — for a post, from the day it was
/// published — and up to and including today in the month running now. None where the month has no figure.
/// </param>
public readonly record struct InsightsMonth(string Month, long? PageViews, int DaysMeasured);

Expand Down Expand Up @@ -1082,11 +1082,90 @@ public sealed record InsightsPostsResponse(
/// say.
/// </param>
/// <param name="PageViews">
/// Pages delivered at it since <see cref="InsightsPostsResponse.ViewsFrom"/>, at its own path and where it
/// landed, to whoever the reading is about.
/// Pages delivered at it since the answer's <c>viewsFrom</c>, at its own path and where it landed, to
/// whoever the reading is about.
/// </param>
public sealed record InsightsPost(string Path, string Address, string? Title, string? Published, long PageViews);

/// <summary>
/// One page of a website's posts, each with how much it was read, all from one reading.
/// </summary>
/// <remarks>
/// Read a page at a time on the terms <see cref="PagesResponse"/> is: ask again with <c>offset</c> advanced
/// by the <c>limit</c> asked for, and an offset past the end answers with an empty page rather than an error.
/// Each order is total, so successive pages of one reading neither repeat a post nor skip one.
/// </remarks>
/// <param name="Posts">Posts its sitemap lists now: the figure Settings gives.</param>
/// <param name="Matching">
/// How many of them hold what was searched for in their title or their address; every one of them where
/// nothing was.
/// </param>
/// <param name="ViewsFrom">
/// The month, as <c>yyyy-MM</c> on the website's calendar, the posts' views are counted from, on the terms
/// <see cref="InsightsPostsResponse"/> gives. Nothing where nothing of the website is held, or where no post
/// matched.
/// </param>
/// <param name="MostPageViews">
/// Pages delivered at the most read of the posts that matched, whatever the page, so every page can be drawn
/// to the same scale. Nought where none matched.
/// </param>
/// <param name="Shown">The page, in the order asked for, each post with the figure Insights gives it.</param>
public sealed record PostsPageResponse(
int Posts,
int Matching,
string? ViewsFrom,
long MostPageViews,
IReadOnlyList<InsightsPost> Shown);

/// <summary>
/// What one of a website's posts has done, all from one reading: its views, every month it was read in,
/// and its recent weeks day by day.
/// </summary>
/// <param name="Post">The post, with the figure Insights gives it.</param>
/// <param name="ViewsFrom">
/// The month, as <c>yyyy-MM</c> on the website's calendar, the post's views are counted from, on the terms
/// <see cref="InsightsPostsResponse"/> gives. Nothing where nothing of the website is held.
/// </param>
/// <param name="FirstDay">
/// The first day of the website's record, as <see cref="AllTimeResponse"/> says; nothing where nothing of
/// the website is held.
/// </param>
/// <param name="From">
/// The first day, as <c>yyyy-MM-dd</c>, the post's first month counts its days from: the first of it, or
/// the website's first day or the day the post was published where either falls later in it, and never
/// later than today. Nothing where the post has no months.
/// </param>
/// <param name="To">The moment the post was read at.</param>
/// <param name="CompleteTo">
/// The instant the answer is complete up to: <paramref name="To"/> for everybody, and for people as far as
/// the verdicts beneath every figure reach.
/// </param>
/// <param name="Months">
/// Every month from the post's first to the one running now, oldest first. A month nothing can say how much
/// the post was read in has no figure and no days; a month it was not read in is nought.
/// </param>
/// <param name="Days">
/// Every day from the Monday of the first of its recent weeks to today, on the website's calendar.
/// </param>
public sealed record InsightsPostResponse(
InsightsPost Post,
string? ViewsFrom,
string? FirstDay,
string? From,
DateTimeOffset To,
DateTimeOffset CompleteTo,
IReadOnlyList<InsightsMonth> Months,
IReadOnlyList<InsightsPostDay> Days);

/// <summary>
/// One day of a post's recent weeks.
/// </summary>
/// <param name="Day">The day, as <c>yyyy-MM-dd</c> on the website's calendar.</param>
/// <param name="PageViews">
/// Pages delivered at the post on it, or nothing where it falls before the website was first measured.
/// </param>
public readonly record struct InsightsPostDay(string Day, long? PageViews);

/// <summary>
/// One day posts were published on.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ namespace Dewiride.Analytics.Api.Contracts;
[JsonSerializable(typeof(AllTimeTrafficSeriesResponse))]
[JsonSerializable(typeof(InsightsResponse))]
[JsonSerializable(typeof(InsightsPostsResponse))]
[JsonSerializable(typeof(InsightsPostResponse))]
[JsonSerializable(typeof(PostsPageResponse))]
[JsonSerializable(typeof(AddSiteRequest))]
[JsonSerializable(typeof(SiteSummary))]
[JsonSerializable(typeof(ActionsResponse))]
Expand Down
Loading
Loading