Skip to content

Model the per-post stats response against the wp.com source - #1526

Merged
oguzkocer merged 11 commits into
per-post-statsfrom
per-post-stats-review-fixes
Aug 8, 2026
Merged

Model the per-post stats response against the wp.com source#1526
oguzkocer merged 11 commits into
per-post-statsfrom
per-post-stats-review-fixes

Conversation

@oguzkocer

@oguzkocer oguzkocer commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Description

Follow-up to #1489, targeting per-post-stats branch.

The per-post stats endpoint has no public documentation, so the response types were worked out from live responses. Comparing them against the endpoint's implementation in wpcomclass.wpcom-json-api-stats-post-views-v1-1-endpoint.php and stats_get_post() in wp-content/mu-plugins/stats.php — turned up a field the API sends that wasn't modelled, three types that are wider than what the API can produce, and several doc comments describing behaviour the source contradicts.

Two of those could only be settled against the live API, so four responses were captured from a test site: one post with views, two never-viewed posts, and the site's home page.

Changes

Types

  • StatsPostTarget replaces PostId as the endpoint's path argument, with Post { id } and HomePage variants. The API addresses the home page as post 0, which isn't a valid PostId anywhere else in the crate. stats_utm takes a typed path argument the same way.
  • average, averages.overall and averages.months are now u64. The endpoint casts all three to int before sending them, so a fractional value can't reach the wire — confirmed across all four captured responses. change is left as f64; it's computed from the uncast average and is the one genuine float in the response.
  • post_author is now WpComUserId — on a WordPress.com site the row carries the account's global id, not the site-scoped one UserId names. This needed a deserialize_u64_or_string_as_t in wp_serde_helper, the counterpart to the existing i64 variant.
  • post_date_gmt is now WpGmtDateTime. wp_utc_date_format already parses the MySQL datetime format the API uses, so no new deserializer was needed.
  • From<PostId> for StatsPostTarget resolves the home page id, so callers working from a list that includes it — /stats/top-posts reports one — don't each repeat the check.

Fields the API sends

  • permalinkstats_get_post() attaches a computed permalink to the post row, and it reaches the wire as the row's last key. Callers previously had no way to get a post's URL from this response. Typed Option<String> and read through deserialize_false_as_none: it's derived per request rather than read from a column, so an unexpected shape shouldn't cost the whole response.
  • post_modified_gmt — the unambiguous counterpart to post_modified, which is in the site's local timezone.
  • post_excerpt.

Documentation

  • PostId(0) doesn't always mean the home page. The endpoint branches on the site's show_on_front setting: with a "latest posts" front page the figures are the home page's own views, but with a static front page they're the whole site's view history. The response is identical in both cases, so the distinction is documented on StatsPostResponse.
  • highest_week_average is neither weekly nor an average. It's the highest single-day view count across the last few weeks. highest_day_average is the highest monthly average of daily views.
  • years and averages start at 1970 for a target with no views. With no view to anchor on, the endpoint reports every year from 1970 to the present, each with an empty months map — 57 entries for a post published last year. Verified on all three never-viewed posts captured.
  • daily_views is padded, not sparse. A never-viewed post still gets one zero-count entry per day since publication.

Serialization

StatsPostResponse deserializes from the API's fields/data column table but derived Serialize over the flattened shape, so serializing a response produced JSON it could not read back. It now serializes into the column table as well, matching how StatsPostChange handles its own wire form.

wp_serde_helper

deserialize_false_as_none mapped every boolean to None, including true. Its siblings — deserialize_false_or_string, deserialize_false_or_string_or_null, deserialize_u64_or_none — all reject true explicitly, so it now does too. Its doc also listed a missing field as yielding None, which deserialize_with cannot do without #[serde(default)]; that line is removed.

Changelog

  • I've added an entry to CHANGELOG.md under ## [Unreleased], using the Keep a Changelog categories (Added, Changed, Deprecated, Removed, Fixed, Security). Prefix breaking changes with **BREAKING:**.

The helper mapped every boolean to `None`, including `true`. Its siblings
all reject `true` explicitly — `deserialize_false_or_string`,
`deserialize_false_or_string_or_null` and `deserialize_u64_or_none` — so
a value the API is not expected to send was being silently swallowed here
alone. A test case pinned the divergence.

The doc also claimed a missing field yields `None`. Serde does not call
`deserialize_with` for an absent key, so without `#[serde(default)]` a
missing field is a hard error. The new test asserts both halves of that.

Changes:
- Match `Bool(false) | Null` for `None` and error on `Bool(true)`
- Document the `#[serde(default)]` requirement for omitted fields
- Move the `true` case into a new `_errors` test alongside the
  malformed-value case
- Add a test covering a missing field with and without `default`
Checking the module against `class.wpcom-json-api-stats-post-views-v1-1-
endpoint.php` and `stats_get_post()` turned up types that overstate what
the API sends, and docs that describe fields it doesn't have.

Address the home page as `StatsPostTarget` rather than `PostId(0)`. Zero
is not a valid `PostId` anywhere else in the crate, and what the API
counts for it is not obvious enough to leave in a doc comment.
`stats_utm` already takes a typed path param this way.

`average`, `averages.overall` and `averages.months` arrive as integers —
PHP casts all three before sending, and FluxC has modelled them as `Int`
for years. Only `change` is a genuine float.

Changes:
- Add `StatsPostTarget`, with `Post { id }` and `HomePage` variants, and
  take it as the endpoint's path argument
- Type the three average fields as `u64`
- Type `post_author` as `UserId` and `post_date_gmt` as `WpGmtDateTime`
- Add `post_modified_gmt` and `post_excerpt`, both sent but unmodelled
- Serialize the response back into the `fields`/`data` column table it
  parses from, so a serialized response can be read again
- Correct `highest_week_average`, which is the highest single-day count
  of recent weeks rather than a weekly average, and `highest_day_average`,
  which is a monthly average of daily views
- Document that the home page's figures cover the whole site when the
  front page is static, and that `post` is then `null` rather than `false`
- Document that `daily_views` is also empty for a never-viewed target,
  whose history the API replaces with one unusable placeholder row, and
  cover that row in the column-handling test
- Give `post` a `serde` default, matching the two sibling fields
- Make the fixtures' weeks self-consistent: only the current week is
  partial, `average` follows from the days, and `isInfinity` only follows
  a zero week
- Name the e2e trials `post::` to match the other stats trials, and
  resolve the borrowed post id with `try_from`
Captured four responses from a live site to settle two claims the source
alone couldn't. Both were wrong in the module, in opposite directions.

`stats_get_post()` does attach a `permalink`, and it reaches the wire —
it is the 25th key on the post row, after `filter`. The doc comment
asserted the response carried no permalink, and the field was unmodelled,
so callers had no way to reach the post's URL.

A never-viewed post does not get the API's no-history fallback row. Its
daily history is padded from the publication date to today with integer
zeros, so `daily_views` is populated, not empty. What does change is
`years` and `averages`: with no view to anchor on, the API reports every
year from 1970 to the present, each with an empty month map — 57 entries
for a post published last year.

`post-no-views.json` described neither shape. It carried a single 2026
year, which no response can produce.

Changes:
- Add `StatsPostDetails::permalink`, and point `guid` at it
- Note that the post row carries no featured image
- Replace the `daily_views` emptiness note with the padding behaviour
- Document the 1970 year range on `years`
- Rebuild `post-no-views.json` from a real never-viewed response, and
  rename its test to match what it covers
- Keep the fallback-row case in the column-handling test, relabelled: the
  server can still emit it, but it is not the never-viewed path
Comments and tests that explained serde or documented what the types
used to be, rather than what the endpoint sends.

Changes:
- Drop the `#[serde(default)]` guidance from `deserialize_false_as_none`
  and the test asserting serde's missing-field behaviour
- Drop `#[serde(default)]` from the response's `post` field; the endpoint
  always sends it
- Restore the fixtures' weeks and the assertions over them, and add only
  the three fields the post row gained
- Restore the e2e comments and the `homepage` trial name
- Cut the daily-view case for the API's no-history fallback row, which
  is not a shape the endpoint was seen to send
- Cut the notes on how `permalink` is derived and on the absent featured
  image
- Use `fmt::Display` rather than a fully qualified path
`post_stats::` says what the trials cover; `post::` names a noun and sits
next to `top_posts::`, where it reads like a posts endpoint rather than a
stats one. The other stats trials drop their `stats_` prefix because what
remains still describes the endpoint, which isn't true here.

Changes:
- Restore the `post_stats::` trial prefix
- Restore the `as i64` cast on the borrowed post id
@oguzkocer oguzkocer added the Rust label Aug 7, 2026
@oguzkocer
oguzkocer marked this pull request as ready for review August 7, 2026 20:36
@oguzkocer
oguzkocer requested review from jkmassel and nbradbury August 7, 2026 20:36
@wpmobilebot

wpmobilebot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

XCFramework Build

This PR's XCFramework is available for testing. Add to your Package.swift:

.package(url: "https://github.com/automattic/wordpress-rs", branch: "pr-build/1526")

Built from 581c207

@nbradbury

nbradbury commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

@oguzkocer Thanks for tackling this! I'll approve this to unblock it, but Claude's review did find some issues, and the first one involving permalink likely needs to be addressed.

review-pr-1526-per-post-stats-2026-08-08.pdf

Also, I noted the "PostId(0) doesn't always mean the home page" remark and wasn't sure what it meant - does the client need to change how it gets stats for the home page?

@nbradbury nbradbury left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

Changes:
- Report the rejected `true` with `invalid_value`, as the visitors in
  `numeric.rs` do, rather than a custom message
- Use `//` for the note above the two column consts; as a doc comment it
  attached to `PERIOD_COLUMN` alone
The test compared only `daily_views`, so it covered the field the column
table flattens into and nothing else. Re-serializing the reparsed value
and comparing covers every field without needing `PartialEq` on the
record.

Runs over all three fixtures. The home page is the case worth having:
its `post` arrives as boolean `false`, becomes `None`, and serializes as
`null`, so it is the only fixture where the round trip changes the wire
shape.

Compares `serde_json::Value` rather than the serialized strings, since
the response holds `HashMap`s and two maps built from the same JSON do
not iterate in the same order.
`years` documents that a target with no views gets an entry for every
year from 1970, and the fixture carries them, but nothing asserted it.
`permalink` is derived per request rather than read from a column, so it
is the field in the post row most likely to move. Requiring it meant an
unexpected shape cost the whole response, for a URL the response is still
useful without.

Changes:
- Type `permalink` as `Option<String>`, read through
  `deserialize_false_as_none` with a serde default
`/stats/top-posts` reports the home page as a pseudo-entry with id 0, so
callers feeding those ids into this endpoint have to know what 0 means.
The conversion puts the rule in one place.

Changes:
- Add `impl From<PostId> for StatsPostTarget`, mapping the home page id
  to `HomePage`
- Name the id as `HOME_PAGE_POST_ID` rather than repeating the literal
`post_author` on a WordPress.com site carries the account's global id,
which `WpComUserId` names. `UserId` is the wp.org site-scoped id.

`deserialize_i64_or_string_as_t` had no `u64` counterpart, which
`WpComUserId` needs.

Changes:
- Add `deserialize_u64_or_string_as_t` to `wp_serde_helper`
- Type `StatsPostDetails::author_id` as `WpComUserId`
@oguzkocer

Copy link
Copy Markdown
Contributor Author

@nbradbury Thank for the review. I've addressed the ones that made sense and also updated the PR description to reflect some of the changes.

Also, I noted the "PostId(0) doesn't always mean the home page" remark and wasn't sure what it meant - does the client need to change how it gets stats for the home page?

The documentation in the PR description seems somewhat clear and somewhat confusing at the same time: (shared as excerpt below)

The endpoint branches on the site's show_on_front setting: with a "latest posts" front page the figures are the home page's own views, but with a static front page they're the whole site's view history.

I don't know if anything needs to be changed in WPAndroid as a result of this. I suggest having a conversation with Claude on this with the context you have related to WPAndroid implementation.

@oguzkocer
oguzkocer merged commit df47723 into per-post-stats Aug 8, 2026
36 checks passed
@oguzkocer
oguzkocer deleted the per-post-stats-review-fixes branch August 8, 2026 22:43
oguzkocer added a commit that referenced this pull request Aug 8, 2026
* Add wp.com `GET /sites/<site_id>/stats/post/<post_id>` endpoint

Per-post view stats, for the "Latest Post Summary" card in the new
Jetpack app stats. The response carries the post's metadata, like count,
and comment count alongside the view history, so the card needs no
separate post fetch.

Notes from capturing real responses:

- The endpoint accepts no query params. `num`, `date`, and `period` are
  silently ignored, so there is no params struct.
- A week's `change` is `null`, a number, or `{"isInfinity": true}` when
  the previous week had no views. Modelled as `StatsPostViewsChange`,
  which round-trips all three.
- `months` inside `years`/`averages` is `[]` rather than `{}` when empty,
  and `post_author` arrives as a string.

`data` holds the post's entire history — thousands of rows for an old
post — so `recent_daily_views(days)` returns just the trailing window
callers actually render.

Verified against 60 real responses across 15 sites.

* Make `recent_daily_views` walk only the window it returns

It called `daily_views()` first, which materializes the post's entire
history — ~4,800 data points, each with a heap-allocated String, on an
old post — then sliced and copied the tail. That is the cost the method
exists to avoid.

It now resolves the column positions once, walks `data` in reverse, and
takes only the entries it returns. Both accessors share the row reader
via a small private `StatsPostViewsDataColumns`, and both bail early
when `fields` doesn't name the columns, matching `stats_visits`.

The FFI surface is unchanged — verified by diffing the generated Swift
declarations before and after.

Also documents why the post row's `comment_count` is dropped in favour
of `discussion.comment_count`, and records that the three `change` wire
shapes are what 60 real responses across 15 sites produced.

* Flatten the daily view history at parse time

The `daily_views`/`recent_daily_views` helpers were `#[uniffi::export]`
methods on a `uniffi::Record`, which generates callers like:

    uniffi_wp_api_fn_method_..._recent_daily_views(
        FfiConverterTypeStatsPostViewsResponse_lower(self), ...)

Every call lowered the whole response back into Rust — including the
`data` array, thousands of rows on an old post — so the helper cost more
than reading `data` natively. That is backwards from why it existed.

The `fields`/`data` column table is now flattened into a `daily_views`
field while deserializing, and both exported methods are gone. Callers
take a trailing window with `dailyViews.suffix(7)` / `takeLast(7)` — no
FFI at all. The column positions are still read from `fields` rather
than assumed; that just happens once now.

`StatsPostViewsDataValue` becomes private along with the raw shape, so
it no longer appears in the bindings.

Adds a test that reorders the `fields` columns, which nothing previously
covered.

* Rename `stats_post_views` to `stats_post`

The module didn't mirror its endpoint path. Fifteen of the eighteen stats
modules do; the three that don't each have a reason (`stats_summary` has
no segment to mirror, the location ones would be `stats_location_views_*`).
This one had no such excuse, and `_views` undersold the type — the
response also carries the like count, comment count, and post metadata,
which is precisely what makes the endpoint useful.

Two types needed more than the mechanical substitution:

- `StatsPostViewsPost` would have become `StatsPostPost`, so it is now
  `StatsPostDetails`.
- `StatsPostViewsDataPoint` would have become `StatsPostDataPoint`, one
  letter from the existing `StatsPostsDataPoint` in `stats_visits` (posts
  published per period) and unrelated in meaning. UniFFI's namespace is
  flat, so both would sit together in Swift and Kotlin autocomplete. It
  is now `StatsPostDailyView`.

* Simplify the per-post stats module

Findings from four parallel cleanup reviews (reuse, simplification,
efficiency, altitude). Net -67 lines, no behaviour change and no change
to the generated FFI surface.

- Drop the private `RawStatsPostDataValue` in favour of the existing
  public `StatsVisitsDataValue`. It was a character-for-character copy,
  and `stats_subscribers` already imports that type cross-module for the
  same purpose.
- `daily_views` now takes `data` by value and moves each row's period
  string instead of cloning it, into a pre-sized `Vec`. The caller drops
  the rows immediately afterwards, so the ~4,800 clones a long-lived
  post incurred were pure waste.
- Replace `StatsPostChange`'s hand-written `Deserialize`/`Serialize`
  with `#[serde(from, into)]` and two `From` impls — the idiom already
  used for the response 150 lines above. Also retires an
  `#[allow(dead_code)]`.
- Tests call `daily_views` directly rather than parsing a 26-line JSON
  envelope to reach it, collapsing three near-identical tests into one
  `rstest`. Drops a redundant test whose assertions were all made more
  precisely elsewhere.
- The e2e trial resolves its post id inside the closure rather than
  during collection, so unrelated e2e runs no longer pay a serial
  network round trip per site.

Known follow-up, deliberately not done here: the column-table lookup now
exists three times (`stats_visits`, `stats_subscribers`, `stats_post`)
and wants a shared helper. That edits two modules outside this branch.

* Support home page stats (`PostId(0)`)

`/stats/post/0` returns the site's home page — `/stats/top-posts`
reports it as a pseudo-entry with that id — and the API answers with a
full 200 and complete view history. The home page isn't a post, though,
so three fields come back differently:

    post:        false     (a boolean, not an object and not null)
    discussion:  null
    like_count:  null

All three were modelled as required, so the response failed to
deserialize. Comparing a home page payload against a normal post's field
by field, those are the only differences — views, years, averages,
weeks, fields/data and the highest_* trio are identical.

`Option` alone doesn't cover `post`, since `false` is not `null`. Adds a
generic `deserialize_false_as_none` to `wp_serde_helper`, which already
had this quirk covered for `String` and `u64` but not for arbitrary
types. It still errors on a genuinely malformed value rather than
quietly returning `None`.

The new e2e trial also surfaced that three test sites answer any stats
call with `invalid_blog` ("Stats module not enabled"). The existing
trial had been passing on them only because the top-posts lookup failed
first and returned early, so the trial now tolerates that error the way
`stats_region_views_tests` does.

* Trim the stats fixtures

The three fixtures carried 314 lines where 191 exercise the same code
paths. No assertion changed.

- Month maps went from 7-8 entries per year to two; the shape under test
  is the map, not its length.
- `homepage.json` came out of the capture with every day and data row
  expanded over four lines. Reformatting it to match the other two
  fixtures accounts for about a fifth of the saving on its own.
- The unmodelled fields on the post row went from 13 to 3. They exist to
  prove serde ignores what we don't model, and three do that as well as
  thirteen. Kept `post_content` (a large ignored field), `comment_count`
  (the string-typed one we deliberately read from `discussion` instead),
  and `filter`.
- `post-no-views.json` had two identical `months: []` years and two
  full-length all-zero weeks; one year and two short weeks still cover
  both quirks it tests.

Everything asserted survives: the seven-day first week, the four-day
partial week, the `{"isInfinity": true}` third week, all five data rows,
and every scalar.

* Model the per-post stats response against the wp.com source (#1526)

* Reject `true` in `deserialize_false_as_none`, and correct its doc

The helper mapped every boolean to `None`, including `true`. Its siblings
all reject `true` explicitly — `deserialize_false_or_string`,
`deserialize_false_or_string_or_null` and `deserialize_u64_or_none` — so
a value the API is not expected to send was being silently swallowed here
alone. A test case pinned the divergence.

The doc also claimed a missing field yields `None`. Serde does not call
`deserialize_with` for an absent key, so without `#[serde(default)]` a
missing field is a hard error. The new test asserts both halves of that.

Changes:
- Match `Bool(false) | Null` for `None` and error on `Bool(true)`
- Document the `#[serde(default)]` requirement for omitted fields
- Move the `true` case into a new `_errors` test alongside the
  malformed-value case
- Add a test covering a missing field with and without `default`

* Model the per-post stats response against the wp.com source

Checking the module against `class.wpcom-json-api-stats-post-views-v1-1-
endpoint.php` and `stats_get_post()` turned up types that overstate what
the API sends, and docs that describe fields it doesn't have.

Address the home page as `StatsPostTarget` rather than `PostId(0)`. Zero
is not a valid `PostId` anywhere else in the crate, and what the API
counts for it is not obvious enough to leave in a doc comment.
`stats_utm` already takes a typed path param this way.

`average`, `averages.overall` and `averages.months` arrive as integers —
PHP casts all three before sending, and FluxC has modelled them as `Int`
for years. Only `change` is a genuine float.

Changes:
- Add `StatsPostTarget`, with `Post { id }` and `HomePage` variants, and
  take it as the endpoint's path argument
- Type the three average fields as `u64`
- Type `post_author` as `UserId` and `post_date_gmt` as `WpGmtDateTime`
- Add `post_modified_gmt` and `post_excerpt`, both sent but unmodelled
- Serialize the response back into the `fields`/`data` column table it
  parses from, so a serialized response can be read again
- Correct `highest_week_average`, which is the highest single-day count
  of recent weeks rather than a weekly average, and `highest_day_average`,
  which is a monthly average of daily views
- Document that the home page's figures cover the whole site when the
  front page is static, and that `post` is then `null` rather than `false`
- Document that `daily_views` is also empty for a never-viewed target,
  whose history the API replaces with one unusable placeholder row, and
  cover that row in the column-handling test
- Give `post` a `serde` default, matching the two sibling fields
- Make the fixtures' weeks self-consistent: only the current week is
  partial, `average` follows from the days, and `isInfinity` only follows
  a zero week
- Name the e2e trials `post::` to match the other stats trials, and
  resolve the borrowed post id with `try_from`

* Model `permalink`, and correct the no-views shape against real responses

Captured four responses from a live site to settle two claims the source
alone couldn't. Both were wrong in the module, in opposite directions.

`stats_get_post()` does attach a `permalink`, and it reaches the wire —
it is the 25th key on the post row, after `filter`. The doc comment
asserted the response carried no permalink, and the field was unmodelled,
so callers had no way to reach the post's URL.

A never-viewed post does not get the API's no-history fallback row. Its
daily history is padded from the publication date to today with integer
zeros, so `daily_views` is populated, not empty. What does change is
`years` and `averages`: with no view to anchor on, the API reports every
year from 1970 to the present, each with an empty month map — 57 entries
for a post published last year.

`post-no-views.json` described neither shape. It carried a single 2026
year, which no response can produce.

Changes:
- Add `StatsPostDetails::permalink`, and point `guid` at it
- Note that the post row carries no featured image
- Replace the `daily_views` emptiness note with the padding behaviour
- Document the 1970 year range on `years`
- Rebuild `post-no-views.json` from a real never-viewed response, and
  rename its test to match what it covers
- Keep the fallback-row case in the column-handling test, relabelled: the
  server can still emit it, but it is not the never-viewed path

* Trim the per-post stats diff to the changes the API requires

Comments and tests that explained serde or documented what the types
used to be, rather than what the endpoint sends.

Changes:
- Drop the `#[serde(default)]` guidance from `deserialize_false_as_none`
  and the test asserting serde's missing-field behaviour
- Drop `#[serde(default)]` from the response's `post` field; the endpoint
  always sends it
- Restore the fixtures' weeks and the assertions over them, and add only
  the three fields the post row gained
- Restore the e2e comments and the `homepage` trial name
- Cut the daily-view case for the API's no-history fallback row, which
  is not a shape the endpoint was seen to send
- Cut the notes on how `permalink` is derived and on the absent featured
  image
- Use `fmt::Display` rather than a fully qualified path

* Keep the e2e trial names and the post id cast as they were

`post_stats::` says what the trials cover; `post::` names a noun and sits
next to `top_posts::`, where it reads like a posts endpoint rather than a
stats one. The other stats trials drop their `stats_` prefix because what
remains still describes the endpoint, which isn't true here.

Changes:
- Restore the `post_stats::` trial prefix
- Restore the `as i64` cast on the borrowed post id

* Match the crate's error style, and unattach the column comment

Changes:
- Report the rejected `true` with `invalid_value`, as the visitors in
  `numeric.rs` do, rather than a custom message
- Use `//` for the note above the two column consts; as a doc comment it
  attached to `PERIOD_COLUMN` alone

* Assert the whole response round trips, over every fixture

The test compared only `daily_views`, so it covered the field the column
table flattens into and nothing else. Re-serializing the reparsed value
and comparing covers every field without needing `PartialEq` on the
record.

Runs over all three fixtures. The home page is the case worth having:
its `post` arrives as boolean `false`, becomes `None`, and serializes as
`null`, so it is the only fixture where the round trip changes the wire
shape.

Compares `serde_json::Value` rather than the serialized strings, since
the response holds `HashMap`s and two maps built from the same JSON do
not iterate in the same order.

* Cover the 1970 year range in the no-views test

`years` documents that a target with no views gets an entry for every
year from 1970, and the fixture carries them, but nothing asserted it.

* Let a missing `permalink` be `None` rather than a parse failure

`permalink` is derived per request rather than read from a column, so it
is the field in the post row most likely to move. Requiring it meant an
unexpected shape cost the whole response, for a URL the response is still
useful without.

Changes:
- Type `permalink` as `Option<String>`, read through
  `deserialize_false_as_none` with a serde default

* Convert a `PostId` to a target, resolving the home page id

`/stats/top-posts` reports the home page as a pseudo-entry with id 0, so
callers feeding those ids into this endpoint have to know what 0 means.
The conversion puts the rule in one place.

Changes:
- Add `impl From<PostId> for StatsPostTarget`, mapping the home page id
  to `HomePage`
- Name the id as `HOME_PAGE_POST_ID` rather than repeating the literal

* Type the post's author as `WpComUserId`

`post_author` on a WordPress.com site carries the account's global id,
which `WpComUserId` names. `UserId` is the wp.org site-scoped id.

`deserialize_i64_or_string_as_t` had no `u64` counterpart, which
`WpComUserId` needs.

Changes:
- Add `deserialize_u64_or_string_as_t` to `wp_serde_helper`
- Type `StatsPostDetails::author_id` as `WpComUserId`

---------

Co-authored-by: Oguz Kocer <oguzkocer@users.noreply.github.com>
Co-authored-by: Oguz Kocer <oguz.kocer@automattic.com>
@nbradbury

Copy link
Copy Markdown
Contributor

For completeness, here's what Claude had to say about home page stats:

How we retrieve home page stats doesn’t need to change. But the caveat is real and worth recording.

What the comment is pointing at. The PostId(0) note is about interpretation, not retrieval. From the doc on StatsPostResponse in rs trunk:

StatsPostTarget::HomePage requests post 0 … What the view figures cover then depends on how the site’s front page is configured, and the two cases are indistinguishable in the response:

a “latest posts” front page — the views recorded against the home page
a static front page — the whole site’s view history
There’s no alternative endpoint, parameter or id for the second case — the request is /stats/post/0 either way. So there is nothing to change about how we fetch it. Our data source already does the only correct thing: it passes through whatever id the Posts & Pages row carries, and maps 0 to StatsPostTarget.HomePage.

It’s also not a regression. Old stats routes that same row’s id 0 to the same endpoint via PostDetailStore, so both screens have always shown whatever WP.com returns for post 0. Nothing this branch did changed that.

On the From for StatsPostTarget they added “so callers working from a list that includes it don’t each repeat the check” — that’s a plain Rust trait impl with no #[uniffi::export], and trait impls don’t cross the FFI boundary. I confirmed the generated Kotlin has no equivalent helper. So my if (postId == HOME_PAGE_POST_ID) HomePage else Post(postId) is the Kotlin counterpart, not a duplicate of something available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants