Y/M/D and a Digit: Composing Chart Ranges from a Unit and a Number
September 23, 2026
The stock chart on Phosphor’s finance door had five time windows, chosen by five keys: 1D, 3M, 6M, 1Y, and 0 for “all history.” That’s the standard menu — every finance app has it. And like the standard menu, it had a wall you hit the first time you actually used it: you can’t ask for 3Y. Or 2M. Or 10D. Every range anyone might want is either pre-built or impossible.
Fixed keys fix the keymaker’s guesses
The original design had a ChartRange enum with five constants, each carrying a hard-coded start date. The keys mapped 1:1 onto the constants. It’s the simplest thing that could work, and it works right up until a user wants a range nobody pre-built — at which point the code grows a sixth constant, a seventh key, a sixth status-bar label, and the combinatorics creep in. Ranges are the kind of feature that looks like a fixed set and is actually a 2-dimensional space.
Because that space is what users actually think in. “How has this done since March?” is a unit question (months) and an amount question (how many months since March?). “What did it do last week?” is 7 days. Nobody thinks in enum constants.
The model: unit × amount
The new ChartRange is a value type, not an enum:
ChartRange.of(ChartUnit.YEARS, 5) // "5Y"
ChartRange.of(ChartUnit.DAYS, 5) // "5D"
ChartRange.ALL // full history, no start-date filter
Two fields — a ChartUnit (YEARS/MONTHS/DAYS) and an int amount — plus the ALL sentinel for unbounded history. Immutable, value equality, and one factory. The entire fixed-5-key enum collapses into a unit and a number.
The key map splits the two dimensions across the keyboard, which is the part that made it click:
- Y / M / D pick the unit, with an immediate chart refresh
- 1 / 2 / 3 / 5 pick the amount
- 0 is “all”
Press M then 3: three months. Press Y then 3: three years — a range the old enum couldn’t express at all. Eight keys now cover the whole product space that five keys could only sample.
The constraint that makes it simple
The design carries one deliberate restriction: daily bars, always. The unit controls how far back the window reaches — never how the data is aggregated. A 5Y chart is five years of daily bars, not weekly or monthly resampling. This kills an entire class of complexity (weekly/monthly bar construction, per-unit label formats) in exchange for one honest behavior: every chart is every daily bar inside the window. For a price-history chart, that’s the right trade — resampled aggregates are where off-by-one week boundaries and partial-candle artifacts live, and none of that machinery needs to exist.
What falls out of a value type
Once a range is data — unit × amount with equals — everything downstream stops having opinions:
- The chart title and the full-screen chart’s label are derived from the range (
"3M","5Y","All"), not stored separately and never allowed to drift from the selection. - The full-screen history chart can infer a range’s label from its computed start date, so a chart opened from a deep link describes itself correctly even when it wasn’t the screen that chose the range.
ChartRange.of(unit, amount)is total: any unit with any amount is a valid range, so there’s no invalid-state class to test around. The tests assert on the value type —of(YEARS,5).label()is5Y,ALLhas no start date, equality is structural — 437 tests green across the screen changes.
The general shape
This refactor is small — one new value type, one key handler, one derived-label function — but the pattern it implements is worth naming: when a UI surface offers a fixed list of N combinations, check whether users are really choosing one dimension or two. Fixed 5D/5M keys were two more rows in a lookup table; unit+amount is the space itself. The fixed version isn’t wrong — it’s just an enumeration of the cases somebody bothered to pre-build, and the cost of pre-building is that nobody can leave the list.