BI and Data Visualization

Scatterplots Where Time Isn't on the X-Axis

By Sean Miller
Scatterplots Where Time Isn't on the X-Axis

Build a connected scatterplot where every line is anchored to its own day zero. FIXED LODs convert calendar time into elapsed time. The pattern that turns spaghetti charts into cohort curves.

In 2021, I exported my entire Spotify listening history.

Not for any particular reason. I was curious. I’d been playing the same Metallica record on repeat for about a month and wondered if that was actually a pattern in my listening or just a recency effect. The CSV was 80,000 rows. I dropped it into Tableau and started digging.

The first chart I built was a calendar-time line chart. One line per song. It looked like every spaghetti chart you’ve ever seen on Reddit. Useless. Beautiful in a “this is just visual noise” way, but still useless.

Then it hit me: the X-axis is wrong. I didn’t care when I’d listened to a song in calendar terms — I cared how a song’s relationship with me had grown over its own lifetime in my library. Some songs are sprinters. (Camila Cabello’s “My Oh My”—30 plays in 38 days, then steady state from there.) Some are slow burns. (Imagine Dragons’ “Believer”—160 plays over 1,515 days, ramping up over six months until it became one of my most-played tracks ever.)

The chart that surfaced that pattern needed an X-axis of days since first listen, not calendar date. And to get there, I needed to anchor every song to its own day zero.

That’s the rep. And it ports cleanly into every cohort analysis a SaaS company has ever wanted to do. Let me show you.

What you're building

A connected scatterplot where:

  • Each line is one entity — one song, one customer, one ticket, whatever you’re cohorting.
  • The X-axis is elapsed days since that entity’s first event — not calendar date.
  • The Y-axis is a running cumulative count — total plays, total logins, total tickets resolved, etc.

Songs from 2017 and songs from 2024 both start at day zero on this chart. The visual collapses calendar time and surfaces shape — fast climbers, slow burns, one-and-dones, steady state.

Why your stakeholders care

Anytime you compare cohorts, lifecycles, onboarding journeys, ramp curves, or post-signup behavior, calendar time is the wrong axis. You don’t want to compare what customers did in March. You want to compare what customers did in their first 30 days, regardless of when they signed up.

Same chart pattern. Replace “song” with “customer.” Replace “first listen” with “signup date.” Replace “plays” with “logins,” “revenue,” or “support tickets resolved.” The cohort curve was sitting there the whole time. You just had to anchor every series to its own day zero.

I’ve shipped this pattern in SaaS (customer activation curves), support (ticket lifecycle), onboarding (completion curves), and marketing (channel ramp curves). Same two FIXED LODs. Different domain, different headline.

Under the hood

The “Set” up — do this first

When we get to interactivity, we’re going to do that with a set action, and we need a set to do that. Right-click on SongID, scroll to Create, click Set, name it Selected Song, and do not add any values to the set. We’ll do that later.

Step 1 — Calc: First Listen (the anchor) — or “Signup Date”

[First Listen]      
DATE({ FIXED [Song ID] : MIN([TimeStamp]) })

For every Song ID, this returns the date of that song’s earliest play. Pinned per entity. It doesn’t move when you filter by date range, and it doesn’t change when you adjust chart granularity — it’s anchored to the song.

In a SaaS context, this is { FIXED [Customer ID] : MIN([Signup Date]) }. Same pattern, different field. The whole rep pivots on this calc.

Step 2 — Calc: Days since first listen — or “Days since signup”

[Days since first listen]        
DATEDIFF('day', [First Listen], [TimeStamp])    

For every play (or login, or ticket event), this calculates how many days have passed since the first play of that specific song.

Drop this on Columns and set it to continuous. Your X-axis is now an integer representing “elapsed days into this entity’s relationship with the metric” — not a calendar date.

Step 3 — Calcs: Total Plays — or “Cumulative logins”

We need to create the window function that calculates our running sum.

//Total Plays
RUNNING_SUM(COUNT([SongID]))
//Total Plays_dot    
       
   IF ATTR([Days Since First Listen]) = MAX({ FIXED [Song ID]:MAX([Days Since First Listen])})
THEN [Cumulative Plays]
END

A running cumulative count of plays per song. Yes, I realize the dot part could have been a TOTAL() function, but we’re going to use this again later, so it makes sense to repurpose it. Drop this on Rows.

This is a table calculation, and the direction matters more than anything else in this rep. After you drop it on Rows:

  • Right-click the pill → Edit Table Calculation
  • Compute Using → Specific Dimensions
  • Check Days since first listen, leave Song ID unchecked
  • At the level: Days since first listen

If you set it the other way — computing across Song ID — your curves go horizontal instead of climbing. (I learned this the hard way around 2 a.m. during the first build.)

From here you’ve got a scatterplot. But the fun part of this challenge is the interactivity. Remember that we have a normalized baseline — we may want to see whether a particular song was a one-hit wonder or an all-time favorite. We need to show the cumulative line trend for that, so we’ll create a similar formula to the one above:

//Total Plays_line    
       
   IF MAX([Selected State])
THEN [Cumulative Plays]
END

What’s happening here is that, because Cumulative Plays is a window function, it’s already aggregated, and all dimensions referenced in an aggregated calculation must also be aggregated. A set simply evaluates whether a member is part of the selected items or not. So under the hood, it’s a Boolean, and the MAX() of TRUE and FALSE is TRUE because T comes before F alphabetically.

So when the set is TRUE, we’ll get the running sum of plays line chart for the selected song. And we’ll make the selection dynamic with set actions.

Now drop both _dot and _line onto the Rows shelf and sync the axes (because in 2026 this is still not the default).

On Rows, make sure _line is on the left and _dot is on the right.

Step 4 — Marks setup

  • Detail: Song ID — so each song gets its own line
  • Color: Song ID, or another dimension like Album for grouping (I usually use Album in production — too many songs makes the legend unreadable)
  • Mark type: Line and Circle if you want pure scatter; I prefer Line because the curve shape is the whole point
  • Then in the Marks card, select the appropriate mark type for each pill and size accordingly (measure with your heart)

Step 5 — Calc: Total Plays_filter (cut the long tail)

There’s one more calc that earns its keep — and this is the one I almost left out of the original because I didn’t think I’d need it.

[Total Plays_filter]      
{ FIXED [Song ID] : COUNT([Song ID]) }

A FIXED count of total plays per song. Drop this on Filters → At least 25 (or whatever your threshold is).

Why? Because if you don’t filter the long tail of one-and-done songs out of this chart, it becomes pure visual noise. With 80,000 rows of data, I have hundreds of songs I played exactly once and never again. They show up as a single dot at day zero with a value of 1. They don’t tell you anything except that I spent a lot of time on YouTube watching the wrong videos.

In a SaaS context, this is the “only show me cohorts with enough data to be meaningful” filter. Cut it at 30 customers, or 100, or whatever your statistical-significance threshold is.

Step 6 — The Action to find the signal among the noise

This is where we add some polish to the whole thing. Let’s set up a set action to select any song (this is why we have SongID on Detail in the Marks card). From the menu bar:

Dashboard → Actions → Add New → Change Set Value

  • Name: Change Song
  • Source Sheet: Scatter
  • Run on: Select
  • Target Set: Selected Song
  • Running this action will: Assign values to set
  • Clearing the selection will: Remove values from set

That’s it.

Form check

Three things to watch:

FIXED runs before dimension filters. This is a feature, not a bug — but it surprises people. If you filter the chart by date range and First Listen doesn’t change, that’s why. The FIXED LOD is computed before your date filter is applied, so the anchor stays put. Which is exactly what you want for cohort analysis. (If you ever need FIXED to respect a filter, add it to Context. But not here.)

Table calc direction. I said it above and I’ll say it again because it’s the single most common mistake I see. Total Plays must compute across Days since first listen, at the level of Song ID. If you set it the other way, the curves go flat instead of climbing. Always sanity-check by hovering over a single song and confirming the running total looks right.

One-row songs draw degenerate lines. If a song has exactly one play, its “line” is a single point at day zero. Some users find this distracting. The fix is the threshold filter from Step 5 — set a minimum count and the noise disappears. Alternatively, you can build a Song ID Line Size boolean ({ FIXED [Song ID]: COUNT([Song ID]) } > 1) and use it to drive Size on the Marks card so single-point songs get hidden.

Same rep, different jersey

SaaS. Customer activation curves. “How fast did this cohort hit their first ten logins?” Replace song with customer, plays with logins.

Support. Ticket lifecycle curves. “How long until 80% of new tickets are resolved?” Replace song with ticket, plays with resolution events. Add a Y-axis cap at 100%, and you’ve got a cumulative resolution curve.

Onboarding. Completion curves. “When do users in this segment hit ‘aha’ relative to signup?” Replace song with user, plays with completion events. Color by acquisition channel, and you’ve got a comparison.

Marketing. Channel ramp curves. “How quickly does each channel reach steady-state attribution?” Replace song with campaign, plays with attributed conversions. Color by channel.

The chart is about songs. The pattern is about cohorts. Once you see the FIXED LOD as the anchor that converts calendar time into elapsed time, you’ll find places to use it everywhere.

Want this run live with your team?

If your team is staring at a calendar-axis line chart, wishing it could compare cohorts that signed up months apart, this is exactly the workshop you want.

WOW Live is a custom Workout Wednesday session I run virtually with your team, using your data, your stack, and your team’s questions. The cohort-curve pattern is the single highest-ROI rep I teach for SaaS and customer success teams. We’ll build it on your activation data, in your environment, with your team’s actual cohort questions.

Sign up for WOW Live

Until next time

This is Rep 3 of 5. Rep 1 — Bar Charts That Show Context and Rep 2 — Line Charts That Tell a Story are both live.

Up next is Rep 4 — Layout: Containers Are the Floor Plan — Brad Werner’s container challenge from 2020, plus the nested LOD that pins every chart in a small-multiples dashboard to the same Y-axis. The structural rep that has saved me more billable hours than any other technique on this list.

The whole series lives at the Workout to Workday hub.

A question I’d love to hear from you: what’s a cohort question your team has been chewing on that calendar-time charts can’t answer? Drop me a line. The answer is almost always, “we just need to anchor every series to its own day zero,” and I’m always happy to sketch the build.

Until next time, GO FORTH AND VIZ.

— Sean


Sean Miller is Principal Consultant for Analytics & BI at Concord, based out of his awesome hometown of Kansas City. He blogs at
hipstervizninja.com and somehow ended up doing #WorkoutWednesday for nine years running. (Don't judge.) Find him on Tableau Public as @hipstervizninja.

Sign up to receive our bimonthly newsletter!
White envelope icon symbolizing email on a purple and pink gradient background.

Not sure on your next step? We'd love to hear about your business challenges. No pitch. No strings attached.

Concord logo
©2026 Concord. All Rights Reserved  |
Privacy Policy