---
title: "Reminder timing for fitness apps | Snowplow Signals"
description: "Snowplow Signals tells your messaging tool each user's usual training hour, whether they trained today and the streak at risk, so the reminder reaches them."
source: https://signals.snowplow.io/solutions/health-fitness/reminders
---

[Health and fitness](https://signals.snowplow.io/solutions/health-fitness) / Reminder timing 

# Send the reminder to the one who has not trained yet. 

Signals tells your messaging tool the hour each user usually trains, whether they have finished a workout today and how long their streak is, so the reminder reaches the user who has not trained by their usual hour and stays quiet for the one who trained at lunch.

[Start free](https://signals.snowplow.io/start) [Talk to us](https://signals.snowplow.io/start?intent=talk) 

More streaks kept going

## Keep more streaks going by timing the reminder to each user's own day. 

A streak survives when the reminder reaches the right person before the day ends, and stays away from the one who has already trained. Signals gives your messaging tool each user's usual training hour, how many workouts they have finished today, the days they trained this month and how long since the last one. Your messaging tool decides who gets a reminder, which one, and when.

Four ways fitness apps use it

01 Evening regulars who have not started Trains around seven most nights, nothing by eight. Remind them now. 02 Users who trained at lunch Finished a session at lunch. Send nothing tonight. 03 Users one day from losing a long streak Trained every day this month, nothing yet today. Say what is at stake. 04 Users whose streak broke yesterday Trained daily for weeks, missed yesterday. Send a restart, not silence. 

[Read the docs →](https://docs.snowplow.io/docs/signals/introduction/) 

### Send the reminder when their usual hour has passed, to them only

A user finishes a workout around seven most evenings. Tonight it is eight and the app has recorded nothing. The daily reminder went out at six to everyone, so it reached this user an hour before they would have trained anyway, and reached the lunchtime crowd hours after they had finished. The reminder that keeps this streak is the one that goes to this user once their usual hour has passed with no workout, and to nobody else.

What Signals knows

`usual_workout_hour_30d` 

The hour of day they most often finish a workout, over the last 30 days

`workouts_1d` 

Workouts finished in the last day

What your messaging tool does

Sends the reminder to a user whose usual hour has passed with no workout finished today, and skips the user whose usual hour is still ahead of them. The message and the channel stay with your messaging tool.

Define it · PythonUse it · TypeScript

reminders.ts · 14 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, AtomicProperty, Event, StreamAttributeGroup, user_id

workout_completed = Event(name="workout_completed", vendor="com.example", version="1-0-0")

usual_workout_hour_30d = Attribute(
    name="usual_workout_hour_30d",
    type="int32",
    events=[workout_completed],
    aggregation="most_frequent",
    property=AtomicProperty(name="derived_tstamp", date_part="hour_of_day"),
    period=timedelta(days=30),
)

workouts_1d = Attribute(
    name="workouts_1d",
    type="int32",
    events=[workout_completed],
    aggregation="counter",
    period=timedelta(days=1),
    default_value=0,
)

# Users are signed in to the app, so this group keys on the user id.
fit_reminders_evening = StreamAttributeGroup(
    name="fit_reminders_evening",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[usual_workout_hour_30d, workouts_1d],
)
```

```
// In the job that decides tonight's reminder sends, for each user
const user = await signals.getServiceAttributes({
  name: "fit_reminders_evening",
  attribute_key: "user_id",
  identifier: userId,
});
// { usual_workout_hour_30d: 19, workouts_1d: 0 }

// The hour comes from the event timestamp, which is UTC, so compare in UTC.
const hourNow = new Date().getUTCHours();

if (hourNow > user.usual_workout_hour_30d && user.workouts_1d === 0) {
  push.send({ userId, template: "usual_time_nudge" });
}
```

### Hold the reminder for anyone who has trained today

A user did their workout at lunch, and at six the daily reminder tells them to work out. A reminder built on a schedule lags behind the day, so it reaches people who have done the thing it asks for. Each of those teaches the user that this notification can be swiped away, and the next one, the one that would have saved a streak, gets swiped away too.

What Signals knows

`workouts_1d` 

Workouts finished in the last day

`hours_since_workout` 

Hours since their last finished workout

What your messaging tool does

Drops a user from tonight's send when they have finished a workout today, and leaves the reminder slot for the next day the count is still zero at their usual hour.

Define it · PythonUse it · TypeScript

reminders.ts · 11 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, StreamAttributeGroup, user_id

workout_completed = Event(name="workout_completed", vendor="com.example", version="1-0-0")

workouts_1d = Attribute(
    name="workouts_1d",
    type="int32",
    events=[workout_completed],
    aggregation="counter",
    period=timedelta(days=1),
    default_value=0,
)

hours_since_workout = Attribute(
    name="hours_since_workout",
    type="double",
    events=[workout_completed],
    aggregation="time_since_last",
    time_unit="h",
)

fit_reminders_trained = StreamAttributeGroup(
    name="fit_reminders_trained",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[workouts_1d, hours_since_workout],
)
```

```
// In the job that decides tonight's reminder sends, for each user
const user = await signals.getServiceAttributes({
  name: "fit_reminders_trained",
  attribute_key: "user_id",
  identifier: userId,
});
// { workouts_1d: 1, hours_since_workout: 6.4 }

if (user.workouts_1d > 0) {
  push.suppress({ userId, campaign: "daily_reminder", until: "tomorrow" });
}
```

### Name the streak in the reminder when the streak is at risk

A user has finished a workout every day for three weeks, and today's has not happened. The daily nudge reads the same for them as for someone on day two, and most of the time that is fine. Tonight it is not. The message that keeps this streak going is the one that says how many days are at stake, sent to this user once their usual hour has passed and the count for today is still zero.

What Signals knows

`active_days_30d` 

The days on which they finished a workout, over the last 30 days

`workouts_1d` 

Workouts finished in the last day

What your messaging tool does

Counts back from yesterday through the list of active days to get the run, and when the run is long and today is still empty, sends the streak message with that count in place of the daily nudge. The run length is counted in your code, so the threshold is yours to set.

Define it · PythonUse it · TypeScript

reminders.ts · 21 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, AtomicProperty, Event, StreamAttributeGroup, user_id

workout_completed = Event(name="workout_completed", vendor="com.example", version="1-0-0")

# One entry per calendar day with a finished workout, so the streak is counted in code.
active_days_30d = Attribute(
    name="active_days_30d",
    type="string_list",
    events=[workout_completed],
    aggregation="unique_list",
    property=AtomicProperty(name="derived_tstamp", date_part="active_day"),
    period=timedelta(days=30),
)

workouts_1d = Attribute(
    name="workouts_1d",
    type="int32",
    events=[workout_completed],
    aggregation="counter",
    period=timedelta(days=1),
    default_value=0,
)

fit_reminders_streak = StreamAttributeGroup(
    name="fit_reminders_streak",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[active_days_30d, workouts_1d],
)
```

```
// In the job that decides tonight's reminder sends, for each user
const user = await signals.getServiceAttributes({
  name: "fit_reminders_streak",
  attribute_key: "user_id",
  identifier: userId,
});
// { active_days_30d: ["2026-09-16", "2026-09-17", ..., "2026-10-06"], workouts_1d: 0 }

// Count back from yesterday; today is still open.
const active = new Set(user.active_days_30d);
const day = new Date();
day.setUTCDate(day.getUTCDate() - 1);
let streak = 0;
while (active.has(day.toISOString().slice(0, 10))) {
  streak += 1;
  day.setUTCDate(day.getUTCDate() - 1);
}

if (user.workouts_1d === 0 && streak >= 14) {
  push.send({ userId, template: "streak_at_risk", streak });
}
```

### Send a restart the morning after a streak breaks

A user who trained every day for weeks missed yesterday, and the counter in the app is back at zero. Most reminder setups go quiet at this point, or keep sending the evening nudge the user now has a reason to ignore. The win-back flow for lapsed members arrives weeks later, after one missed day has turned into a quiet month. The message that restarts the streak is a different one, sent the next morning, and it says the habit is still there and today starts the new run.

What Signals knows

`days_since_workout` 

Days since their last finished workout

`active_days_30d` 

The days on which they finished a workout, over the last 30 days

What your messaging tool does

Sends a restart message on the morning after a long run ends, once the gap since the last workout passes a day and the active days show a long run up to it, and leaves the lapse campaign for the user who stays away longer.

Define it · PythonUse it · TypeScript

reminders.ts · 23 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, AtomicProperty, Event, StreamAttributeGroup, user_id

workout_completed = Event(name="workout_completed", vendor="com.example", version="1-0-0")

days_since_workout = Attribute(
    name="days_since_workout",
    type="double",
    events=[workout_completed],
    aggregation="time_since_last",
    time_unit="d",
)

active_days_30d = Attribute(
    name="active_days_30d",
    type="string_list",
    events=[workout_completed],
    aggregation="unique_list",
    property=AtomicProperty(name="derived_tstamp", date_part="active_day"),
    period=timedelta(days=30),
)

fit_reminders_broken = StreamAttributeGroup(
    name="fit_reminders_broken",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[days_since_workout, active_days_30d],
)
```

```
// In the job that decides the morning sends, for each user
const user = await signals.getServiceAttributes({
  name: "fit_reminders_broken",
  attribute_key: "user_id",
  identifier: userId,
});
// { days_since_workout: 1.6, active_days_30d: ["2026-09-14", ..., "2026-10-05"] }

// The run that ended: count back from the day before the miss.
const active = new Set(user.active_days_30d);
const day = new Date();
day.setUTCDate(day.getUTCDate() - 2);
let runBeforeMiss = 0;
while (active.has(day.toISOString().slice(0, 10))) {
  runBeforeMiss += 1;
  day.setUTCDate(day.getUTCDate() - 1);
}

const missedOneDay = user.days_since_workout >= 1 && user.days_since_workout < 2;

if (missedOneDay && runBeforeMiss >= 14) {
  push.send({ userId, template: "fresh_start", previousStreak: runBeforeMiss });
}
```

## Try it on your own site. 

Add the SDK, define one thing you want to know about a visitor, and watch it update while you click around. The free tier runs on the real engine: no card, no sales call.

[Start free](https://signals.snowplow.io/start) [Talk to an engineer](https://signals.snowplow.io/start?intent=engineer)
