This document details how ChemPal records the price of every product (and each of its variants) over time so a user can see whether a price has gone up or down since they last checked. Prices are captured in standardized USD, stored in IndexedDB, and surfaced as a trend indicator and sparkline in the product detail panel.
usdPrice — the USD anchor computed at build time by ProductBuilder.build() via toUSD(). Storing USD (not the supplier's local price or the user's display currency) keeps points comparable across time and lets the UI convert to any display currency on the fly via formatDisplayPrice.trackPriceHistory setting — it runs whether or not the supplier caches are enabled.price_history store is user-accumulated data, so it is deliberately excluded from clearAllCaches. It has its own "Clear price history" button in settings.priceHistoryMaxPoints caps how many points each series keeps (oldest trimmed first); 0 (the default) means unlimited.Recording is fire-and-forget from the two fetch branches of useSearch (recordProductPrices(products, userSettings)), never from the session-restore path. Each series is an independent IndexedDB row, so a changed price appends one point and everything else is a no-op.
The price_history object store (IndexedDB chempal database, IDB_STORE.PRICE_HISTORY) holds one row per product-or-variant series. It mirrors the single-row-holds-an-array pattern used elsewhere, so a dedup check is a single read + conditional write. The productKey index lets the UI pull a product's base row plus all of its variant rows in one query.
| Field | Type | Notes |
|---|---|---|
id |
string |
Series id — ${productKey} (base) or ${productKey}::${variantKey} (variant). Key path of the store. |
productKey |
string |
Product identity, shared by the base row and every variant row. Indexed. |
variantKey |
string? |
Variant discriminator used in the id (genuine variant id, else title/size). Absent on the base row. |
variantId |
string? |
The variant's own supplier id when it is genuinely per-variant — lets a variant be looked up by its exact id. |
supplier |
string |
Supplier display name (kept so history renders without a live product). |
title |
string |
Product/variant title for display. |
permalink |
string? |
Human-facing link back to the product. |
points |
{ t: number; usd: number }[] |
Observed prices, ascending by time; a point appended on each USD-price change. |
updatedAt |
number |
Epoch ms of the last write to this series. |
Price tracking lives or dies on stable, unique identity at two levels — the product and the variant. Get it wrong and distinct listings collapse into one series (fake trends) or one listing splits across many (lost history).
The product key is getProductIdentityKey(product.cacheKey ?? product.url ?? product.title, product.supplier) (an MD5 of { key, supplier }). cacheKey is stamped at parse time from each supplier's SupplierBase.getUniqueProductKey(item). That key must identify the specific purchasable listing, not a broader grouping.
The cautionary example is Ambeed. Its API returns three related fields:
| Field | Meaning | Unique per listing? |
|---|---|---|
p_id |
the compound (≈ CAS) — e.g. one value for "Hyaluronic acid sodium salt" | ❌ shared across brands |
p_am |
the listing (a brand's offering of that compound; the ?am= in the URL) |
✅ |
pr_id |
the size variant (1mg, 5mg, …) within a listing | ✅ globally |
Keying products on p_id (the compound) collapsed 8 different brand listings of one compound onto a single identity, which:
findDuplicateProductIds),The fix was to key on p_am (the listing), which getUniqueProductKey now returns. The lesson generalizes: a supplier's "product id" is only a valid identity if it is unique per listing.
Variants are keyed by a discriminator appended to the product key, chosen in priority order:
ProductBuilder.build() fills unset variant fields from the parent, so siblings routinely inherit the same parent sku/id; an inherited id is ignored.quantity + uom),Two safeguards back this up:
Two optional user settings control tracking (defaults in config.json, validated in typeGuards/common.ts, edited in the Price History section of SettingsPanel):
| Setting | Type | Default | Meaning |
|---|---|---|---|
trackPriceHistory |
boolean |
true |
Master on/off. When off, recordProductPrices is a no-op. |
priceHistoryMaxPoints |
number |
0 |
Max points kept per series; 0 = unlimited. Oldest points trimmed first. |
The settings section also exposes a Clear price history button (clearPriceHistory()), separate from the cache-clear actions so accumulated history isn't wiped by a routine cache clear.
The expanded product detail panel (ProductDetailPanel) loads a product's series via getProductPriceHistory(product) and renders, per product and per variant:
describeTrend(points) — a colored glyph plus the signed delta and percent change since the previous point (rising = red, drop = green),The product-level summary shows the default variant's series (the variant whose price is the product's headline price), consistent with the base/variant dedup above. All values run through formatDisplayPrice, so history converts to the user's currency exactly like the results table.
The chempal debug object (dev builds only) exposes read helpers for inspection:
chempal.getProductPriceHistory(id) — series for a product or a variant. Pass a product id/cacheKey/uuid/_id to get the base + all variant series, or a variant id/sku/title to get just that variant's series.| File | Responsibility |
|---|---|
src/helpers/priceHistory.ts |
recordProductPrices, getProductPriceHistory, describeTrend, and the key derivation (productSeriesKey, variantSeriesKey) |
src/utils/idbCache.ts |
price_history store schema + CRUD (getPriceSeries, putPriceSeries, getPriceSeriesByProduct, clearPriceHistory) |
src/components/SearchPanel/hooks/useSearch.ts |
Recording seam — calls recordProductPrices from the fetch branches |
src/components/SearchPanel/ProductDetailPanel.tsx |
Trend + sparkline UI |
src/helpers/productIdentity.ts |
getProductIdentityKey (shared product identity) |
src/helpers/price.ts |
formatDisplayPrice (USD → display currency) |