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.
The data
Stillpoint meditation app, beta group sessions, September to October 2025
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.
member | session_date | start_time | minutes |
|---|---|---|---|
| Diego Alvarez | 2025-09-01 | 07:47 | 15 |
| Maya Chen | 2025-09-01 | 07:55 | 15 |
| Sam Whitley | 2025-09-01 | 12:35 | 10 |
| Sam Whitley | 2025-09-01 | 21:37 | 20 |
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 sessionsThe 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, memberRows
| Member | Sessions | Active days |
|---|---|---|
| Maya Chen | 83 | 53 |
| Ana Sousa | 80 | 46 |
| Theo Okafor | 56 | 33 |
| Diego Alvarez | 49 | 32 |
| Priya Raman | 57 | 32 |
| Kenji Mori | 31 | 20 |
| Lena Vogel | 28 | 16 |
| Sam Whitley | 22 | 16 |
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])) - 1SQL
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_dateRows
| Member | Session date | Days missed before |
|---|---|---|
| Ana Sousa | Sep 1, 2025 | – |
| Ana Sousa | Sep 3, 2025 | 1 |
| Ana Sousa | Sep 4, 2025 | 0 |
| Ana Sousa | Sep 6, 2025 | 1 |
| Ana Sousa | Sep 7, 2025 | 0 |
| Ana Sousa | Sep 9, 2025 | 1 |
| Ana Sousa | Sep 10, 2025 | 0 |
| Ana Sousa | Sep 11, 2025 | 0 |
| Ana Sousa | Sep 12, 2025 | 0 |
| Ana Sousa | Sep 13, 2025 | 0 |
| Ana Sousa | Sep 14, 2025 | 0 |
| Ana Sousa | Sep 15, 2025 | 0 |
| Ana Sousa | Sep 16, 2025 | 0 |
| Ana Sousa | Sep 18, 2025 | 1 |
| Ana Sousa | Sep 19, 2025 | 0 |
| Ana Sousa | Sep 21, 2025 | 1 |
| Ana Sousa | Sep 22, 2025 | 0 |
| Ana Sousa | Sep 23, 2025 | 0 |
| Ana Sousa | Sep 24, 2025 | 0 |
| Ana Sousa | Sep 25, 2025 | 0 |
| Ana Sousa | Sep 26, 2025 | 0 |
| Ana Sousa | Sep 27, 2025 | 0 |
| Ana Sousa | Sep 28, 2025 | 0 |
| Ana Sousa | Sep 29, 2025 | 0 |
| Ana Sousa | Oct 1, 2025 | 1 |
| Ana Sousa | Oct 2, 2025 | 0 |
| Ana Sousa | Oct 3, 2025 | 0 |
| Ana Sousa | Oct 4, 2025 | 0 |
| Ana Sousa | Oct 5, 2025 | 0 |
| Ana Sousa | Oct 6, 2025 | 0 |
| Ana Sousa | Oct 8, 2025 | 1 |
| Ana Sousa | Oct 9, 2025 | 0 |
| Ana Sousa | Oct 12, 2025 | 2 |
| Ana Sousa | Oct 13, 2025 | 0 |
| Ana Sousa | Oct 14, 2025 | 0 |
| Ana Sousa | Oct 15, 2025 | 0 |
| Ana Sousa | Oct 18, 2025 | 2 |
| Ana Sousa | Oct 20, 2025 | 1 |
| Ana Sousa | Oct 21, 2025 | 0 |
| Ana Sousa | Oct 22, 2025 | 0 |
| Ana Sousa | Oct 23, 2025 | 0 |
| Ana Sousa | Oct 25, 2025 | 1 |
| Ana Sousa | Oct 27, 2025 | 1 |
| Ana Sousa | Oct 28, 2025 | 0 |
| Ana Sousa | Oct 29, 2025 | 0 |
| Ana Sousa | Oct 31, 2025 | 1 |
| Diego Alvarez | Sep 1, 2025 | – |
| Diego Alvarez | Sep 2, 2025 | 0 |
| Diego Alvarez | Sep 3, 2025 | 0 |
| Diego Alvarez | Sep 8, 2025 | 4 |
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
ENDSQL
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_dateRows
| Member | Session date | Days missed before | Streak no | Day of streak |
|---|---|---|---|---|
| Ana Sousa | Sep 1, 2025 | – | 1 | 1 |
| Ana Sousa | Sep 3, 2025 | 1 | 2 | 1 |
| Ana Sousa | Sep 4, 2025 | 0 | 2 | 2 |
| Ana Sousa | Sep 6, 2025 | 1 | 3 | 1 |
| Ana Sousa | Sep 7, 2025 | 0 | 3 | 2 |
| Ana Sousa | Sep 9, 2025 | 1 | 4 | 1 |
| Ana Sousa | Sep 10, 2025 | 0 | 4 | 2 |
| Ana Sousa | Sep 11, 2025 | 0 | 4 | 3 |
| Ana Sousa | Sep 12, 2025 | 0 | 4 | 4 |
| Ana Sousa | Sep 13, 2025 | 0 | 4 | 5 |
| Ana Sousa | Sep 14, 2025 | 0 | 4 | 6 |
| Ana Sousa | Sep 15, 2025 | 0 | 4 | 7 |
| Ana Sousa | Sep 16, 2025 | 0 | 4 | 8 |
| Ana Sousa | Sep 18, 2025 | 1 | 5 | 1 |
| Ana Sousa | Sep 19, 2025 | 0 | 5 | 2 |
| Ana Sousa | Sep 21, 2025 | 1 | 6 | 1 |
| Ana Sousa | Sep 22, 2025 | 0 | 6 | 2 |
| Ana Sousa | Sep 23, 2025 | 0 | 6 | 3 |
| Ana Sousa | Sep 24, 2025 | 0 | 6 | 4 |
| Ana Sousa | Sep 25, 2025 | 0 | 6 | 5 |
| Ana Sousa | Sep 26, 2025 | 0 | 6 | 6 |
| Ana Sousa | Sep 27, 2025 | 0 | 6 | 7 |
| Ana Sousa | Sep 28, 2025 | 0 | 6 | 8 |
| Ana Sousa | Sep 29, 2025 | 0 | 6 | 9 |
| Ana Sousa | Oct 1, 2025 | 1 | 7 | 1 |
| Ana Sousa | Oct 2, 2025 | 0 | 7 | 2 |
| Ana Sousa | Oct 3, 2025 | 0 | 7 | 3 |
| Ana Sousa | Oct 4, 2025 | 0 | 7 | 4 |
| Ana Sousa | Oct 5, 2025 | 0 | 7 | 5 |
| Ana Sousa | Oct 6, 2025 | 0 | 7 | 6 |
| Ana Sousa | Oct 8, 2025 | 1 | 8 | 1 |
| Ana Sousa | Oct 9, 2025 | 0 | 8 | 2 |
| Ana Sousa | Oct 12, 2025 | 2 | 9 | 1 |
| Ana Sousa | Oct 13, 2025 | 0 | 9 | 2 |
| Ana Sousa | Oct 14, 2025 | 0 | 9 | 3 |
| Ana Sousa | Oct 15, 2025 | 0 | 9 | 4 |
| Ana Sousa | Oct 18, 2025 | 2 | 10 | 1 |
| Ana Sousa | Oct 20, 2025 | 1 | 11 | 1 |
| Ana Sousa | Oct 21, 2025 | 0 | 11 | 2 |
| Ana Sousa | Oct 22, 2025 | 0 | 11 | 3 |
| Ana Sousa | Oct 23, 2025 | 0 | 11 | 4 |
| Ana Sousa | Oct 25, 2025 | 1 | 12 | 1 |
| Ana Sousa | Oct 27, 2025 | 1 | 13 | 1 |
| Ana Sousa | Oct 28, 2025 | 0 | 13 | 2 |
| Ana Sousa | Oct 29, 2025 | 0 | 13 | 3 |
| Ana Sousa | Oct 31, 2025 | 1 | 14 | 1 |
| Diego Alvarez | Sep 1, 2025 | – | 1 | 1 |
| Diego Alvarez | Sep 2, 2025 | 0 | 1 | 2 |
| Diego Alvarez | Sep 3, 2025 | 0 | 1 | 3 |
| Diego Alvarez | Sep 8, 2025 | 4 | 2 | 1 |
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 memberSQL
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_dayRows
| Member | Session date | Session date | Streak days |
|---|---|---|---|
| Theo Okafor | 2025-09-19 | 2025-10-10 | 22 |
| Kenji Mori | 2025-10-12 | 2025-10-31 | 20 |
| Priya Raman | 2025-10-18 | 2025-10-31 | 14 |
| Sam Whitley | 2025-09-01 | 2025-09-12 | 12 |
| Ana Sousa | 2025-09-21 | 2025-09-29 | 9 |
| Ana Sousa | 2025-09-09 | 2025-09-16 | 8 |
| Maya Chen | 2025-09-07 | 2025-09-13 | 7 |
| Maya Chen | 2025-09-15 | 2025-09-21 | 7 |
| Maya Chen | 2025-09-30 | 2025-10-06 | 7 |
| Maya Chen | 2025-10-08 | 2025-10-14 | 7 |
| Ana Sousa | 2025-10-01 | 2025-10-06 | 6 |
| Maya Chen | 2025-09-23 | 2025-09-28 | 6 |
| Maya Chen | 2025-10-16 | 2025-10-21 | 6 |
| Maya Chen | 2025-09-01 | 2025-09-05 | 5 |
| Maya Chen | 2025-10-23 | 2025-10-27 | 5 |
| Ana Sousa | 2025-10-12 | 2025-10-15 | 4 |
| Ana Sousa | 2025-10-20 | 2025-10-23 | 4 |
| Diego Alvarez | 2025-09-10 | 2025-09-13 | 4 |
| Priya Raman | 2025-10-05 | 2025-10-08 | 4 |
| Ana Sousa | 2025-10-27 | 2025-10-29 | 3 |
| Diego Alvarez | 2025-09-01 | 2025-09-03 | 3 |
| Diego Alvarez | 2025-10-01 | 2025-10-03 | 3 |
| Diego Alvarez | 2025-10-14 | 2025-10-16 | 3 |
| Maya Chen | 2025-10-29 | 2025-10-31 | 3 |
| Priya Raman | 2025-09-06 | 2025-09-08 | 3 |
| Priya Raman | 2025-09-17 | 2025-09-19 | 3 |
| Ana Sousa | 2025-09-03 | 2025-09-04 | 2 |
| Ana Sousa | 2025-09-06 | 2025-09-07 | 2 |
| Ana Sousa | 2025-09-18 | 2025-09-19 | 2 |
| Ana Sousa | 2025-10-08 | 2025-10-09 | 2 |
| Diego Alvarez | 2025-09-15 | 2025-09-16 | 2 |
| Diego Alvarez | 2025-09-28 | 2025-09-29 | 2 |
| Diego Alvarez | 2025-10-09 | 2025-10-10 | 2 |
| Diego Alvarez | 2025-10-19 | 2025-10-20 | 2 |
| Diego Alvarez | 2025-10-26 | 2025-10-27 | 2 |
| Lena Vogel | 2025-09-06 | 2025-09-07 | 2 |
| Lena Vogel | 2025-09-13 | 2025-09-14 | 2 |
| Lena Vogel | 2025-09-20 | 2025-09-21 | 2 |
| Lena Vogel | 2025-09-27 | 2025-09-28 | 2 |
| Lena Vogel | 2025-10-04 | 2025-10-05 | 2 |
| Lena Vogel | 2025-10-11 | 2025-10-12 | 2 |
| Lena Vogel | 2025-10-18 | 2025-10-19 | 2 |
| Lena Vogel | 2025-10-25 | 2025-10-26 | 2 |
| Priya Raman | 2025-09-21 | 2025-09-22 | 2 |
| Priya Raman | 2025-09-24 | 2025-09-25 | 2 |
| Priya Raman | 2025-09-29 | 2025-09-30 | 2 |
| Sam Whitley | 2025-09-15 | 2025-09-16 | 2 |
| Theo Okafor | 2025-09-02 | 2025-09-03 | 2 |
| Theo Okafor | 2025-09-10 | 2025-09-11 | 2 |
| Theo Okafor | 2025-10-18 | 2025-10-19 | 2 |
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 logSQL
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, memberRows
| Member | Active days | Longest streak | Current streak |
|---|---|---|---|
| Theo Okafor | 33 | 22 | 0 |
| Kenji Mori | 20 | 20 | 20 |
| Priya Raman | 32 | 14 | 14 |
| Sam Whitley | 16 | 12 | 0 |
| Ana Sousa | 46 | 9 | 1 |
| Maya Chen | 53 | 7 | 3 |
| Diego Alvarez | 32 | 4 | 0 |
| Lena Vogel | 16 | 2 | 0 |