Skip to content

feat(rank-tracking): record which field a stored position came from - #177

Open
Nordalux wants to merge 1 commit into
every-app:mainfrom
Nordalux:feat/rank-snapshot-position-basis
Open

feat(rank-tracking): record which field a stored position came from#177
Nordalux wants to merge 1 commit into
every-app:mainfrom
Nordalux:feat/rank-snapshot-position-basis

Conversation

@Nordalux

Copy link
Copy Markdown
Contributor

Problem

#429 changed new rank checks from rank_absolute to rank_group, which is the right number to show — I'm not proposing to revert that. But rank_snapshots.position is a single integer column with nothing recording which of the two a row holds, and the two are not on the same scale: for the same result, rank_absolute is worse by the number of SERP features above it.

That matters because positions are compared across rows. rankTrackingResults.ts builds previousPositions from the snapshot before the view's cutoff date, and falls back to the earliest snapshot when there is none:

// getSnapshotsBeforeDate(configId, targetDate) → previousPositions
// then, for keyword+device combos with no comparison snapshot:
//   getEarliestSnapshotsForKeywords(configId, uniqueMissingIds)

So it isn't a one-off blip on the first check after upgrading. Any view whose comparison window reaches back before the switch shows a delta computed across two different definitions, and for keywords whose history starts before the switch the earliest-snapshot fallback keeps a rank_absolute baseline indefinitely. The user sees an improvement of roughly the number of SERP features above their result — a local pack plus PAA is easily two to four positions of movement that never happened.

Change

Add a nullable position_basis column to rank_snapshots and set it when writing. null means the pre-#429 rank_absolute, resolved in exactly one place:

export function resolvePositionBasis(stored: string | null | undefined): PositionBasis {
  return stored === "rank_group" ? "rank_group" : "rank_absolute";
}

Both writers go through mapResultsToSnapshotRows in rankCheckPaths.ts, so one line covers the live and queued paths.

No backfill, deliberately. The raw SERP items aren't stored — only the feature type list — so rank_group cannot be recomputed for existing rows. Recording "unknown" is honest; guessing would put wrong numbers into a series users read as history.

No behaviour change. Nothing reads the column yet. That's the point of splitting it out: every check written without the column produces another row whose meaning can only be inferred from its timestamp later, so capturing it is worth doing before the part that needs a decision.

Follow-up, not in this PR

The fix that uses this is to reject a comparison snapshot whose basis differs from the current one, so previousPosition comes back null and no delta is shown. That path already exists — createEmptyDeviceResult(null) and toDeviceResult(snapshot, null) handle "no history" today — so it needs no new UI.

I left it out because it's a behaviour change and you may want it presented differently, for instance an explicit hint rather than silently no delta. Happy to open it once you've said which you'd prefer.

One design question I'd rather ask than assume: rank_check_runs is arguably the tidier home, since a run is homogeneous — one code version produced it. I put the column on the snapshot because the three read paths (getLatestSnapshotsForKeywords, getSnapshotsBeforeDate, getEarliestSnapshotsForKeywords) all return snapshot rows and a run-level field would need three joins. Say the word if you'd rather have it on the run.

Verification

  • Both migrations are a plain additive nullable column: ALTER TABLE rank_snapshots ADD position_basis text; for D1 and the Postgres equivalent. No rewrite, no default backfill.
  • schema-parity.test.ts (120 cases) passes, covering the two dialects.
  • Four cases added to src/shared/rank-tracking.test.ts pinning that new writes are rank_group, that a missing or unrecognised basis reads back as rank_absolute, and that a stored basis passes through.
  • pnpm ci:check passes. Full pnpm test:ci: 772 passed.

Verified and tested by hand. The description was drafted with AI assistance — I'd rather let the diff do the talking than a long write-up.

#429 switched new rank checks from `rank_absolute` to `rank_group`. That is the
right number to show, but `rank_snapshots.position` holds both meanings now
with nothing to tell them apart, and the two are not on the same scale: for the
same result `rank_absolute` is worse by the number of SERP features above it.

Add a nullable `position_basis` column and set it on write. Null means the
pre-#429 rank_absolute, resolved in one place by `resolvePositionBasis` so the
legacy meaning does not get re-derived at each call site.

No backfill, deliberately: the raw SERP items are not stored, so `rank_group`
cannot be recomputed for existing rows. Guessing would be worse than recording
that we do not know.

This changes no behaviour — nothing reads the column yet. It is separate so the
metadata starts being captured now; every check written without it produces
another row whose meaning can only be inferred from its timestamp later.

Both migrations are a plain additive nullable column. schema-parity covers the
two dialects.
HasimEmre pushed a commit to HasimEmre/open-seo that referenced this pull request Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant