# `MishkaBlog.Dashboard.Analytics`
[🔗](https://github.com/mishka-group/mishka_blog/blob/v0.0.1/lib/mishka_blog/dashboard/analytics.ex#L5)

Every number on the blog analytics page (`/admin/dashboard/blog/analytics`), read from the database.

`load/2` reads one window — seven, thirty or ninety whole days ending today — and returns the KPI
figures, the daily views series, the posts read most in that window, where their readers came from
and whether those readers were signed in. Nothing is estimated: the page carries a metric only
where `MishkaBlog.PostView` actually records one, which is why there is no read time, no session
length and no bounce rate — nothing in the schema observes a reader leaving.

A view row is written once per post per reader (per account when signed in, per IP otherwise) and
never updated, so "views" here means *distinct readers who opened a post*, and `inserted_at` is the
first time each one did. That is the unit every figure below is in.

Every figure is read as `user` through `MishkaBlog.Dashboard.Scope`, as
`MishkaBlog.Dashboard.Overview` reads its own: a master (a user whose `site_id` is nil) counts every
site, a site user only its own. `MishkaBlog.PostView` has no master read and carries no `site_id`;
both viewers read it through `:read`, which its policies keep to the viewer's site, joined to its
post (`not is_nil(post.id)`), so a view of an archived post is not counted.

The window is read once — five selected columns over one read — and the series, the reader count,
the referrers, the audience split and the top posts are folded out of those rows in memory. Each of
those panels is a `GROUP BY` that an Ash read does not express, and one narrow read beats five
hand-written queries. The all-time and previous-window totals stay `Ash.count/2` calls, as they are
single numbers.

Views and posts are read only for a holder of `admin:dashboard:blog:post`, and comments only for a
holder of `admin:dashboard:blog:comment` (`MishkaBlog.Dashboard.Overview.areas/1`) — the
permissions those resources' policies ask for. A read of an area the viewer does not hold is
refused and counted as no data, so its figures are zero and an engagement rate needs both.

The caller runs this off the render — see `MishkaBlogWeb.Live.Admin.Dashboard.Analytics.Index`.

# `default_days`

```elixir
@spec default_days() :: pos_integer()
```

The window the page opens on, in whole days.

# `load`

```elixir
@spec load(map(), pos_integer()) :: map()
```

Reads the analytics page for the last `days` days, as `user` (see `MishkaBlog.Dashboard.Scope`).

Returns a flat map: the all-time and windowed view totals, the window before it to compare
against, today and yesterday, the distinct readers behind them, the comment engagement rate, and
four collections — `:series` (one `%{date, count}` per day of the window, zeros included),
`:top_posts` (`%{title, status, published_at, views}`, most read first), `:referrers`
(`%{label, count}`, busiest first) and `:audience` (`%{members, guests}`).

`:from` and `:to` are the window's own bounds, so the page can say which days it is describing
rather than assuming they match the `days` it asked for.

# `ranges`

```elixir
@spec ranges() :: [pos_integer()]
```

The windows the page offers, in whole days.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
