# Freshness and source dates

What "observed at" means, what a tape day is, how a stored fact ages, when it counts as stale, and what a requested refresh actually changes.

Every fact PeekPanda shows you has a date attached. That date is not decoration. It is the difference between "this app ranks #12" and "this app ranked #12 when we last looked, which was Tuesday".

PeekPanda serves stored observations. Opening a page reads what has already been collected; it does not go out to the store and wait. So the date is the honest description of what you are looking at.

## Observed at, observed on, fetched at

Three timestamps do different jobs. They are not interchangeable.

| Field | What it means |
| --- | --- |
| `observedAt` | The precise moment the fact was read from the public store surface. A full timestamp. |
| `observedOn` | The **UTC calendar day** that reading belongs to. A date, no time. This is the deduplication key. |
| `fetchedAt` | When a chart snapshot was pulled by PeekPanda. |
| `sourceUpdatedAt` | When the store itself says it last updated that feed, where the feed publishes it. Often earlier than `fetchedAt`, and sometimes absent. |

`sourceUpdatedAt` and `fetchedAt` can disagree, and when they do the store's own stamp is the more truthful one. A feed published at 04:00 and collected at 09:00 describes the store at 04:00.

## What a tape day is

The **rating tape** is the sequence of daily readings of an app's public lifetime rating count. It is the raw material for the install estimate.

One tape point is one row, and the row's identity is:

```text
(platform, store_id, country, source, observed_on)
```

That composite key is why the tape holds **exactly one point per app, per country, per UTC day**. Collect the same app twice in one day and you get one row, not two. The day boundary is UTC, not your local time.

Currently only the **US** storefront is taped.

So "two tape days" means two rows with different `observed_on` values. Two readings taken four hours apart on the same UTC day are one tape day, and they will not produce an install estimate. See [how estimates are computed](/docs/how-estimates-are-computed) for why two days are required.

Schema: `packages/db/src/schema/store-rating-observation.ts`.

## Listing facts keep only the latest reading

The rating tape keeps history. The rest of the listing does not.

Descriptive store facts (name, description, developer, price, version, screenshots, genres) are stored as **one row per app per country**, overwritten each time the listing is read. There is one `observedAt` on that row, and it is the date of the most recent successful read. There is no archive of what the listing said last month.

So a case file's About tab tells you what the listing says now, as of one date. It cannot tell you when the developer changed the title or rewrote the description. Only the rating count has a dated series behind it.

Schema: `packages/db/src/schema/app-catalog-enrichment.ts`.

## What a chart day is

Ranking feeds are collected per country, per category, per chart type. One collection produces one **snapshot** for that combination on that day.

A chart snapshot carries:

| Field | Meaning |
| --- | --- |
| `day` | The UTC day the snapshot belongs to |
| `fetchedAt` | When PeekPanda pulled it |
| `sourceUpdatedAt` | The store's own stamp for that feed, when published |
| `depth` | How many ranked rows the snapshot actually holds |
| `previousDay` | The day of the snapshot before it, which is what movement is measured against |
| `stale` | Whether the snapshot is older than the staleness window |

`depth` matters when you compare charts. A snapshot with depth 100 and one with depth 40 are not equally complete, and an app "missing" from the shallower one may simply be below its floor.

`previousDay` matters more. Movement (gainers, losers, new entrants) is computed against the **previous stored snapshot**, whatever day that was. It is not always yesterday. If a day was missed, the comparison spans two days, and `previousDay` tells you so.

**The first day has no movement.** A snapshot with no previous day shows ranks and no deltas. It does not show zeroes.

## When something is stale

Staleness is not one global rule. Each kind of fact has its own window, because each ages at a different rate.

| Fact | Rule | Constant |
| --- | --- | --- |
| Rating tape point | The point for **today's** UTC day is missing | The one-row-per-UTC-day primary key |
| Chart snapshot | Older than **30 hours** | `APPLE_CHART_REFRESH_POLICY.staleAfterMs` |
| Keyword search snapshot | Older than **24 hours** | `ASO_RATE_POLICY.queryFreshnessHours = 24` |
| Search-suggestion snapshot (demand) | Older than **7 days** | `ASO_RATE_POLICY.hintFreshnessHours = 24 × 7` |

The tape is the odd one out: it is not aged against a rolling clock, it is checked against the calendar. Either today's row exists or it does not.

The chart window is 30 hours rather than 24 on purpose: feeds do not publish at a fixed hour, so a strict 24-hour rule would mark a perfectly current chart stale for the few hours before the next one lands.

A fact that has **never** been collected is treated as stale too. Never-collected and long-ago-collected both mean "a refresh would fetch this". They are distinguished in what you see: a never-collected number reads *Not collected*, and an aged one reads with its date.

Sources: `packages/db/src/schema/store-rating-observation.ts`, `packages/libraries/src/domain/app-catalog/apple-charts.contract.ts`, `packages/libraries/src/domain/app-catalog/aso-workstation.contract.ts`, and `isAsoSnapshotStale` in `aso-metrics.ts`.

## Stale does not mean wrong

A stale snapshot is still a real observation of a real moment. It is shown with its date, not hidden and not replaced by a guess.

What staleness changes is one thing: whether a refresh would go and fetch it. That is all the flag decides.

## What a requested refresh changes

A refresh does not rewrite history. It appends a new observation and moves the "latest" pointer.

**For keywords.** A refresh collects fresh search results for the queries that are stale or have never been collected, and stores a new snapshot per query. The previous snapshot stays, which is what makes position history possible. Queries that are already fresh are skipped, so pressing refresh when everything is current fetches nothing and says so.

Snapshots are **shared**. A query collected because one person asked for it serves everyone. Two people refreshing the same keyword produce one call to the store, not two.

**For ratings.** A refresh enqueues that day's lookup only when the day's tape point is missing. If today's point already exists, there is nothing to fetch: the tape is one point per day by construction, and a second call would be discarded by the primary key.

Because a refresh appends a day rather than replacing one, the first refresh on a newly tracked app usually cannot produce an install estimate. It creates one tape point. The estimate needs two.

**For charts.** Nothing you press refreshes a chart. Ranking feeds are collected on their own schedule and chart reads never call the store. This is not a limitation being worked around; it is the design. See [plans and refreshes](/docs/plans-and-refreshes) for what a refresh costs against your allowance.

## Refreshes are paced, so they are not instant

Outbound calls to the store are deliberately spaced. Keyword collection waits **4 seconds** between calls from one process; chart collection waits **1 second**. A refresh covering forty queries therefore takes a couple of minutes.

That pacing is a courtesy to a public endpoint that costs nothing to use, and it is the reason the endpoint keeps working. A refresh that returned instantly would be one that hammered the store.

Constants: `ASO_RATE_POLICY.minIntervalMs` and `APPLE_CHART_REFRESH_POLICY.minIntervalMs`.

## Reading a date well

Three habits will keep you honest with this data.

1. **Compare like dates.** Two apps observed on different days are not directly comparable, especially for anything derived from a chart position.
2. **Check `previousDay` before believing a delta.** A large mover across a two-day gap is a different claim from a large mover overnight.
3. **Treat a derived number as only as fresh as its oldest input.** An opportunity score built from a day-old search snapshot and a six-day-old suggestion snapshot is six days old.

## Related

- [How estimates are computed](/docs/how-estimates-are-computed)
- [Where the data comes from](/docs/where-the-data-comes-from)
- [Plans and refreshes](/docs/plans-and-refreshes)
- [Honest unknowns](/docs/honest-unknowns)
