Recipe Tableau and SQL

How do I count consecutive-day streaks and the gaps between them?

Reduce the log to one row per person per day, compare each day with the one before, and start a new streak whenever the gap is too long. In this meditation app, Maya Chen practised on 53 of 61 days, more than anyone, yet her longest streak is 7 days. The longest belongs to Theo Okafor: 22 days.

Updated

The data

Stillpoint meditation app, beta group sessions, September to October 2025

Synthetic datasessions.csv

Eight beta testers of a small meditation app, 61 days of sessions. Some sit twice a day, some only at weekends, one joined halfway through.

membersession_datestart_timeminutes
Diego Alvarez2025-09-0107:4715
Maya Chen2025-09-0107:5515
Sam Whitley2025-09-0112:3510
Sam Whitley2025-09-0121:3720

Why doesn't the most active person have the longest streak?

Because a streak rewards never missing, not showing up often. Maya practised on 53 of 61 days, but she skips one day almost every week, so her runs stop at five to seven days. Theo practised on only 33 days and still holds the longest run, 22 days straight. If your app ranks people by streaks, the rule for a missed day decides who wins.

Step 1

One row per person per day

The log has 406 sessions but only 248 person-days: plenty of people sit twice a day. A streak counts days, so everything after this step works on distinct days. Maya practised on the most days.

Tableau

COUNTD([session_date])
// and COUNT([session_date]) for sessions

The xmetric engine ran this formula and got the SQL’s Active days column, row for row.

SQL

SELECT
  member,
  COUNT(*) : : INTEGER AS sessions,
  COUNT(DISTINCT session_date) : : INTEGER AS active_days
FROM input
GROUP BY member
ORDER BY active_days DESC, member

Rows

MemberSessionsActive days
Maya Chen8353
Ana Sousa8046
Theo Okafor5633
Diego Alvarez4932
Priya Raman5732
Kenji Mori3120
Lena Vogel2816
Sam Whitley2216

Step 2

Find the gap before each day

Each day looks back at the same person's previous active day. Zero means yesterday was active too; three means three empty days came first. The first day of each person has nothing to look back at. In Tableau, put member and session_date (exact date, discrete) on Rows, then Edit Table Calculation: Specific Dimensions, tick only the day, so the calculation moves along the days and restarts for every member.

Tableau

DATEDIFF('day', LOOKUP(MIN([session_date]), -1), MIN([session_date])) - 1

SQL

WITH d AS (
  SELECT DISTINCT member, session_date
  FROM input
)
SELECT
  member,
  session_date,
  (date_diff('day', LAG(session_date) OVER (PARTITION BY member ORDER BY session_date), session_date) - 1) : : INTEGER AS days_missed_before
FROM d
ORDER BY member, session_date

Rows

MemberSession dateDays missed before
Ana SousaSep 1, 2025–
Ana SousaSep 3, 20251
Ana SousaSep 4, 20250
Ana SousaSep 6, 20251
Ana SousaSep 7, 20250
Ana SousaSep 9, 20251
Ana SousaSep 10, 20250
Ana SousaSep 11, 20250
Ana SousaSep 12, 20250
Ana SousaSep 13, 20250
Ana SousaSep 14, 20250
Ana SousaSep 15, 20250
Ana SousaSep 16, 20250
Ana SousaSep 18, 20251
Ana SousaSep 19, 20250
Ana SousaSep 21, 20251
Ana SousaSep 22, 20250
Ana SousaSep 23, 20250
Ana SousaSep 24, 20250
Ana SousaSep 25, 20250
Ana SousaSep 26, 20250
Ana SousaSep 27, 20250
Ana SousaSep 28, 20250
Ana SousaSep 29, 20250
Ana SousaOct 1, 20251
Ana SousaOct 2, 20250
Ana SousaOct 3, 20250
Ana SousaOct 4, 20250
Ana SousaOct 5, 20250
Ana SousaOct 6, 20250
Ana SousaOct 8, 20251
Ana SousaOct 9, 20250
Ana SousaOct 12, 20252
Ana SousaOct 13, 20250
Ana SousaOct 14, 20250
Ana SousaOct 15, 20250
Ana SousaOct 18, 20252
Ana SousaOct 20, 20251
Ana SousaOct 21, 20250
Ana SousaOct 22, 20250
Ana SousaOct 23, 20250
Ana SousaOct 25, 20251
Ana SousaOct 27, 20251
Ana SousaOct 28, 20250
Ana SousaOct 29, 20250
Ana SousaOct 31, 20251
Diego AlvarezSep 1, 2025–
Diego AlvarezSep 2, 20250
Diego AlvarezSep 3, 20250
Diego AlvarezSep 8, 20254

First 50 of 248 rows.

Step 3

Number the streaks

A gap longer than the allowance starts a new streak; anything shorter carries the count on. The Tableau counter reads its own value on the previous row with PREVIOUS_VALUE, so it adds one or resets to 1. SQL turns each reset into a 1 and keeps a running sum of them, which gives every streak its own number. Same Compute Using as the step before.

Tableau

IF DATEDIFF('day', LOOKUP(MIN([session_date]), -1), MIN([session_date])) <= 1 + 0 THEN PREVIOUS_VALUE(0) + 1
ELSE 1
END

SQL

WITH d AS (
  SELECT DISTINCT member, session_date
  FROM input
), g AS (
  SELECT
    member,
    session_date,
    date_diff('day', LAG(session_date) OVER (PARTITION BY member ORDER BY session_date), session_date)
      - 1 AS days_missed_before
  FROM d
), s AS (
  SELECT
    *,
    SUM(CASE WHEN days_missed_before <= 0 THEN 0 ELSE 1 END) OVER (PARTITION BY member ORDER BY session_date ROWS UNBOUNDED PRECEDING) AS streak_no
  FROM g
)
SELECT
  member,
  session_date,
  days_missed_before : : INTEGER AS days_missed_before,
  streak_no : : INTEGER AS streak_no,
  ROW_NUMBER() OVER (PARTITION BY member, streak_no ORDER BY session_date) : : INTEGER AS day_of_streak
FROM s
ORDER BY member, session_date

Rows

MemberSession dateDays missed beforeStreak noDay of streak
Ana SousaSep 1, 2025–11
Ana SousaSep 3, 2025121
Ana SousaSep 4, 2025022
Ana SousaSep 6, 2025131
Ana SousaSep 7, 2025032
Ana SousaSep 9, 2025141
Ana SousaSep 10, 2025042
Ana SousaSep 11, 2025043
Ana SousaSep 12, 2025044
Ana SousaSep 13, 2025045
Ana SousaSep 14, 2025046
Ana SousaSep 15, 2025047
Ana SousaSep 16, 2025048
Ana SousaSep 18, 2025151
Ana SousaSep 19, 2025052
Ana SousaSep 21, 2025161
Ana SousaSep 22, 2025062
Ana SousaSep 23, 2025063
Ana SousaSep 24, 2025064
Ana SousaSep 25, 2025065
Ana SousaSep 26, 2025066
Ana SousaSep 27, 2025067
Ana SousaSep 28, 2025068
Ana SousaSep 29, 2025069
Ana SousaOct 1, 2025171
Ana SousaOct 2, 2025072
Ana SousaOct 3, 2025073
Ana SousaOct 4, 2025074
Ana SousaOct 5, 2025075
Ana SousaOct 6, 2025076
Ana SousaOct 8, 2025181
Ana SousaOct 9, 2025082
Ana SousaOct 12, 2025291
Ana SousaOct 13, 2025092
Ana SousaOct 14, 2025093
Ana SousaOct 15, 2025094
Ana SousaOct 18, 20252101
Ana SousaOct 20, 20251111
Ana SousaOct 21, 20250112
Ana SousaOct 22, 20250113
Ana SousaOct 23, 20250114
Ana SousaOct 25, 20251121
Ana SousaOct 27, 20251131
Ana SousaOct 28, 20250132
Ana SousaOct 29, 20250133
Ana SousaOct 31, 20251141
Diego AlvarezSep 1, 2025–11
Diego AlvarezSep 2, 2025012
Diego AlvarezSep 3, 2025013
Diego AlvarezSep 8, 2025421

First 50 of 248 rows.

Step 4

One row per streak

Group by person and streak number and each island becomes one row with its first day, last day and length. Tableau can't group by a table calculation, so this list is SQL's job; in Tableau, WINDOW_MAX of the counter from the step before gives each person's longest streak on every row.

Tableau

WINDOW_MAX([Day of streak])
// Compute Using: session_date, restarting every member

SQL

WITH d AS (
  SELECT DISTINCT member, session_date
  FROM input
), g AS (
  SELECT
    member,
    session_date,
    date_diff('day', LAG(session_date) OVER (PARTITION BY member ORDER BY session_date), session_date)
      - 1 AS days_missed_before
  FROM d
), s AS (
  SELECT
    *,
    SUM(CASE WHEN days_missed_before <= 0 THEN 0 ELSE 1 END) OVER (PARTITION BY member ORDER BY session_date ROWS UNBOUNDED PRECEDING) AS streak_no
  FROM g
)
SELECT
  member,
  MIN(session_date) AS first_day,
  MAX(session_date) AS last_day,
  COUNT(*) : : INTEGER AS streak_days
FROM s
GROUP BY member, streak_no
ORDER BY streak_days DESC, member, first_day

Rows

MemberSession dateSession dateStreak days
Theo Okafor2025-09-192025-10-1022
Kenji Mori2025-10-122025-10-3120
Priya Raman2025-10-182025-10-3114
Sam Whitley2025-09-012025-09-1212
Ana Sousa2025-09-212025-09-299
Ana Sousa2025-09-092025-09-168
Maya Chen2025-09-072025-09-137
Maya Chen2025-09-152025-09-217
Maya Chen2025-09-302025-10-067
Maya Chen2025-10-082025-10-147
Ana Sousa2025-10-012025-10-066
Maya Chen2025-09-232025-09-286
Maya Chen2025-10-162025-10-216
Maya Chen2025-09-012025-09-055
Maya Chen2025-10-232025-10-275
Ana Sousa2025-10-122025-10-154
Ana Sousa2025-10-202025-10-234
Diego Alvarez2025-09-102025-09-134
Priya Raman2025-10-052025-10-084
Ana Sousa2025-10-272025-10-293
Diego Alvarez2025-09-012025-09-033
Diego Alvarez2025-10-012025-10-033
Diego Alvarez2025-10-142025-10-163
Maya Chen2025-10-292025-10-313
Priya Raman2025-09-062025-09-083
Priya Raman2025-09-172025-09-193
Ana Sousa2025-09-032025-09-042
Ana Sousa2025-09-062025-09-072
Ana Sousa2025-09-182025-09-192
Ana Sousa2025-10-082025-10-092
Diego Alvarez2025-09-152025-09-162
Diego Alvarez2025-09-282025-09-292
Diego Alvarez2025-10-092025-10-102
Diego Alvarez2025-10-192025-10-202
Diego Alvarez2025-10-262025-10-272
Lena Vogel2025-09-062025-09-072
Lena Vogel2025-09-132025-09-142
Lena Vogel2025-09-202025-09-212
Lena Vogel2025-09-272025-09-282
Lena Vogel2025-10-042025-10-052
Lena Vogel2025-10-112025-10-122
Lena Vogel2025-10-182025-10-192
Lena Vogel2025-10-252025-10-262
Priya Raman2025-09-212025-09-222
Priya Raman2025-09-242025-09-252
Priya Raman2025-09-292025-09-302
Sam Whitley2025-09-152025-09-162
Theo Okafor2025-09-022025-09-032
Theo Okafor2025-09-102025-09-112
Theo Okafor2025-10-182025-10-192

First 50 of 72 rows.

Step 5

Longest streak, current streak, active days

Bars are each person's longest streak, the line their active days. They rank people differently: with no missed days allowed, Maya tops active days and sits near the bottom on streaks, because she misses one day almost every week. A streak is current only if it reaches the last day of the log, October 31.

Tableau

WINDOW_MAX([Day of streak])
// filter LAST() = 0 to keep each member's latest day: [Day of streak] there is the current streak if that day is the last day of the log

SQL

WITH d AS (
  SELECT DISTINCT member, session_date
  FROM input
), g AS (
  SELECT
    member,
    session_date,
    date_diff('day', LAG(session_date) OVER (PARTITION BY member ORDER BY session_date), session_date)
      - 1 AS days_missed_before
  FROM d
), s AS (
  SELECT
    *,
    SUM(CASE WHEN days_missed_before <= 0 THEN 0 ELSE 1 END) OVER (PARTITION BY member ORDER BY session_date ROWS UNBOUNDED PRECEDING) AS streak_no
  FROM g
), k AS (
  SELECT
    member,
    MAX(session_date) AS last_day,
    COUNT(*) AS streak_days
  FROM s
  GROUP BY member, streak_no
)
SELECT
  member,
  SUM(streak_days) : : INTEGER AS active_days,
  MAX(streak_days) : : INTEGER AS longest_streak,
  COALESCE(MAX(streak_days) FILTER (WHERE last_day >= (
    SELECT MAX(session_date)
    FROM input
  ) - 0), 0) : : INTEGER AS current_streak
FROM k
GROUP BY member
ORDER BY longest_streak DESC, member

Rows

MemberActive daysLongest streakCurrent streak
Theo Okafor33220
Kenji Mori202020
Priya Raman321414
Sam Whitley16120
Ana Sousa4691
Maya Chen5373
Diego Alvarez3240
Lena Vogel1620

What if one missed day is allowed?

Turn the knob. With 0 missed days allowed, the longest streak is 22 days and it belongs to Theo Okafor. At one, all 53 of Maya's days join into a single streak and she jumps from near the bottom to the top, while Lena, who practises only at weekends, stays at two: five empty weekdays break any allowance up to four. The allowance changes the count of streaks too: 72 at this setting.

02

Pick the allowance before you build the leaderboard, and say it on the page. "Longest streak" with a one-day grace and without one are different numbers about different habits.

Common mistake

Counting sessions instead of days

Without the distinct step, two sessions on one day are two rows: the streak counts both and grows past the number of days it spans. Theo's 22-day streak comes out as 38. Reduce to one row per person per day first.

WrongStarts with Theo Okafor, Kenji Mori, Priya Raman…
Point at the line to read it.
0102030Members in this order →

Tableau

Rows: member, session_date, start_time
// one row per session, so the counter adds one per session

SQL

WITH g AS (
  SELECT
    member,
    session_date,
    date_diff('day', LAG(session_date) OVER (PARTITION BY member ORDER BY session_date), session_date)
      - 1 AS days_missed_before
  FROM input
), s AS (
  SELECT
    *,
    SUM(CASE WHEN days_missed_before <= 0 THEN 0 ELSE 1 END) OVER (PARTITION BY member ORDER BY session_date ROWS UNBOUNDED PRECEDING) AS streak_no
  FROM g
)
SELECT member, MAX(n) : : INTEGER AS longest_streak
FROM (
  SELECT member, streak_no, COUNT(*) AS n
  FROM s
  GROUP BY member, streak_no
)
GROUP BY member
ORDER BY longest_streak DESC, member
RightStarts with Theo Okafor, Kenji Mori, Priya Raman…
Point at the line to read it.
0102030Members in this order →

Tableau

Rows: member, session_date (exact date)
// one row per person per day

SQL

WITH d AS (
  SELECT DISTINCT member, session_date
  FROM input
), g AS (
  SELECT
    member,
    session_date,
    date_diff('day', LAG(session_date) OVER (PARTITION BY member ORDER BY session_date), session_date)
      - 1 AS days_missed_before
  FROM d
), s AS (
  SELECT
    *,
    SUM(CASE WHEN days_missed_before <= 0 THEN 0 ELSE 1 END) OVER (PARTITION BY member ORDER BY session_date ROWS UNBOUNDED PRECEDING) AS streak_no
  FROM g
)
SELECT member, MAX(n) : : INTEGER AS longest_streak
FROM (
  SELECT member, streak_no, COUNT(*) AS n
  FROM s
  GROUP BY member, streak_no
)
GROUP BY member
ORDER BY longest_streak DESC, member

Common mistake

A streak that runs from one person into the next

If the comparison doesn't restart per person, the first day of one member is compared with the last day of the member above. The gap comes out negative, which passes the test, so streaks leak across people: Priya's October run carries straight into Sam's September and makes a 26-day streak nobody had.

WrongStarts with Priya Raman, Diego Alvarez, Theo Okafor…
Point at a bar to read it.
01020Priya RamanDiego AlvarezTheo Okafor

Tableau

Compute Using: Table (down)
// runs across all members

SQL

WITH d AS (
  SELECT DISTINCT member, session_date
  FROM input
), g AS (
  SELECT
    member,
    session_date,
    date_diff('day', LAG(session_date) OVER (ORDER BY member, session_date), session_date)
      - 1 AS days_missed_before
  FROM d
), s AS (
  SELECT
    *,
    SUM(CASE WHEN days_missed_before <= 0 THEN 0 ELSE 1 END) OVER (ORDER BY member, session_date ROWS UNBOUNDED PRECEDING) AS streak_no
  FROM g
)
SELECT
  MIN(member) AS first_member,
  MAX(member) AS last_member,
  COUNT(*) : : INTEGER AS streak_days
FROM s
GROUP BY streak_no
ORDER BY streak_days DESC
LIMIT 3
RightStarts with Theo Okafor, Kenji Mori, Priya Raman…
Point at a bar to read it.
01020Theo OkaforKenji MoriPriya Raman

Tableau

Compute Using: Specific Dimensions, session_date ticked, member unticked
// restarts every member

SQL

WITH d AS (
  SELECT DISTINCT member, session_date
  FROM input
), g AS (
  SELECT
    member,
    session_date,
    date_diff('day', LAG(session_date) OVER (PARTITION BY member ORDER BY session_date), session_date)
      - 1 AS days_missed_before
  FROM d
), s AS (
  SELECT
    *,
    SUM(CASE WHEN days_missed_before <= 0 THEN 0 ELSE 1 END) OVER (PARTITION BY member ORDER BY session_date ROWS UNBOUNDED PRECEDING) AS streak_no
  FROM g
)
SELECT
  MIN(member) AS first_member,
  MAX(member) AS last_member,
  COUNT(*) : : INTEGER AS streak_days
FROM s
GROUP BY member, streak_no
ORDER BY streak_days DESC
LIMIT 3

What counts as a current streak?

A streak that reaches the last day of the data, with the allowance applied. On October 31, Kenji (20 days, every day since he joined) and Priya (14) are on a streak; Theo's 22-day run ended on October 10. If your export stops before today, every current streak is a lower bound, and so is any streak that started before the first day in the file.

Run it on your own data

Any log with a person (or store, sensor, account) and a date works, with any number of rows per day. Drop your CSV and pick the two columns; the steps above recompute in your browser, and nothing is uploaded.

Drop a CSV anywhere on this page

Up to 10,000 rows. The sample, sessions.csv, shows the shape.

Questions

What does gaps and islands mean?

An island is a run of consecutive active days, which an app calls a streak. A gap is the run of empty days between two islands. Both come from one number: the days since the person's previous active day.

How do I let one missed day not break the streak?

Raise the allowed gap. In Tableau, change <= 1 to <= 2 in the streak counter; in SQL, start a new streak only when more than one day was missed. Turn the Missed days allowed knob on this page to see what it does to the leaderboard.

Can I count weekly streaks instead of daily ones?

Yes. Put DATETRUNC('week', [session_date]) on Rows and use DATEDIFF('week', ...) in both formulas. In SQL, take distinct date_trunc('week', session_date) values and compare weeks with date_diff('week', ...).

Can PREVIOUS_VALUE or WINDOW_MAX go inside a FIXED expression?

No. Table calculations can't be used inside FIXED, INCLUDE or EXCLUDE, so the Tableau version needs the day on the view. For one row per person without the days, use the SQL.

My timestamps are in UTC. Does that matter?

Yes. A session at 9 pm in New York falls on the next day in UTC and can join or split a streak. Convert to the person's local time before taking the date.

Sources