---
title: "The price history graph, and the clamp I forgot"
description: "The bar on every Hargapedia product page has been redrawn a few times and is still horizontal, and this is how it is built, along with the classic mistake in the first version."
url: "https://rayson.me/writing/price-graph/"
category: "Engineering"
author: "Janarayson"
published: 2021-08-27
---

Every product page in Hargapedia has a small graph near the top. It shows the lowest and highest price we've seen for the product over the last few months, the usual price somewhere in between, and where today's price sits against all of that. It's one bar with two labels and four numbers on it, and it's one of the more complex components in the app, and the version that went out this month is the latest in a long line of them.

## It has always been a horizontal bar

The first design, from early 2018, already had the shape it has now. A bar from lowest to highest, the usual price marked above it, today's price marked below it with a line down to the retailer offering it. The wireframes for 2.0 kept the same idea, and that's what we built.

<Figure
  src={design2018}
  alt="The first price graph design: Price based on past 6 months, RM20.10 Lowest on the left, RM28.88 Highest on the right, a blue bar between them, a RM23.88 Usual Price label above and a Today RM20.88 label below"
  caption="The first design, February 2018."
/>

<Figure
  src={wireframe2018}
  alt="The 2.0 wireframe of the same graph in grey: RM29.45 Lowest, RM40.10 Highest, a RM32.90 Usual Price label above the bar and a Today RM30.00 label below it with the retailer's logo"
  caption="The same graph in the 2.0 wireframes."
/>

By 2020 it had a green fill from the left edge up to today's price, so you could read at a glance how close to the lowest you were.

<Figure
  src={app2020}
  alt="The graph in the app in 2020: RM4.32 Lowest, RM7.95 Highest, a green fill up to a Today RM4.99 label, and a RM5.80 Usual Price label under the bar"
  caption="In the app, 2020."
/>

When we redesigned the app for 3.0 I tried something else, a line that dipped from the normal price down to today's and back up to the usual, with a big Today pin in the middle. It didn't survive the mockups. The horizontal bar went into 3.0 instead.

<Figure
  src={design2020}
  alt="Two 3.0 designs: on top, a rejected one with Normal RM55.00 on the left, Usual RM39.99 on the right and a pink line dipping to a large Today! RM37.90 pin; below it, the one that shipped, Price based on last 3 months, a Today RM34.95 label above a green and grey bar, RM34.95 Lowest, RM55.00 Highest and a RM39.99 Usual Price label under the bar"
  caption="Two 3.0 designs, September 2020. The line on top was dropped."
/>

This month I had another go, and the mockups had two vertical versions in them, a thin bar standing next to the product photo with the usual price marked on it and today's price as a tag. They were rejected, and frankly they weren't appealing. We're sticking with the horizontal one, with a gradient under the fill and a compact version for the price tracker.

<Screens
  screens={[
    { src: vertical1, alt: 'A rejected vertical graph: a thin bar beside the Milo product photo, Highest RM24.20 at the top, Lowest RM10.49 at the bottom, a Usual RM16.50 marker and a green Today RM12.16 tag' },
    { src: vertical2, alt: 'A second rejected vertical graph, taller, next to a Track Price button, with the same highest, usual, today and lowest markers' },
    { src: bar2021, alt: 'The horizontal graph that shipped: Price based on last 3 months, Today RM12.16 above the bar, RM10.49 Lowest, RM24.20 Highest, and RM16.50 Usual Price below' },
    { src: compact2021, alt: 'The compact version of the bar next to the product photo, with a Tracking RM12.90 label on the right' },
  ]}
  caption="The August 2021 mockups. The two vertical ones on the left did not make it."
/>

## How it's put together

Everything on the bar is a position along it, worked out from the price range. The component measures the bar and both labels when they lay out, then places each label as a fraction of the bar's width.

```tsx
// position along the bar, as a fraction of its measured width
labelPositionUsual =
  ((priceUsual - priceLow) / (priceHigh - priceLow)) * barWidth;
labelPositionToday =
  ((priceToday - priceLow) / (priceHigh - priceLow)) * barWidth;

// center the label on that point
const labelTodayInitial = -this.state.todayLabelWidth / 2;
marginLeft: labelTodayInitial + labelPositionToday
```

The widths come from `onLayout`, which means the first render has nothing to go on and the labels jump into place a frame later. The fill is done back to front. The gradient runs the full width underneath, and a grey block covers it from today's position to the right edge, so the coloured part is whatever is left.

```tsx
<View style={[styles.barGray, { width: barWidth - labelPositionToday }]} />
```

The today label takes its colour from where it lands, green near the lowest and red near the highest, in ten steps. And the usual price label gets special treatment, because a label centered on a point near either end would hang off the bar. If the usual price is within a few percent of the lowest or the highest, the label snaps to that end instead.

```tsx
const handleUsualPosition = () => {
  const clampHigh = parseFloat((priceHigh * 0.96).toFixed(2));
  const clampLow = parseFloat((priceLow * 1.04).toFixed(2));

  if (priceLow === priceHigh) return labelUsualInitial + labelPositionUsual;
  if (priceUsual === priceLow || priceUsual < clampLow) return 0;
  if (priceUsual === priceHigh || clampHigh < priceUsual)
    return barWidth - this.state.usualLabelWidth;

  return labelUsualInitial + labelPositionUsual;
};
```

When the lowest and highest are the same price, everything sits at the halfway mark, otherwise the division above would be by zero.

## The clamp I forgot

The first version had a bug that I should have seen coming. The position formula assumes today's price sits between the lowest and the highest, and most of the time it does, because the summary is built from the same prices. But the summary covers the last few months, and today's price is today's. The moment today's price fell outside that range, the fraction went past zero or past one, and the fill just kept going, off the end of the bar and off the screen. There was nothing stopping it. A very classic mistake.

The fix went into the product screen rather than the graph. If today's price is lower than the summary's lowest, today's price becomes the lowest, and the graph never sees a value outside its own range.

```tsx
// this checks if the price today happens to be below the lowest
// and replaces the summary lowest so the graph doesn't go off
const valueLowest = valueToday && valueToday < low ? valueToday : low;
```

That's where it stands, a horizontal bar measured at runtime, with its labels centered and clamped. The vertical ones are still in the Sketch file.
