← Back to Portfolio
library(tidyverse)
library(readr)
library(ggplot2)
library(tibble)
library(lubridate)
library(janitor)
library(scales)
library(knitr)
library(dplyr)
Sys.setlocale("LC_TIME", "C")  # ensuring English weekday names regardless of system locale
## [1] "C"

Executive Summary

Two months of Fitbit tracker data from 35 users shows the wellness market has been selling the wrong habit. The strongest relationship in the data is not between steps and health it is between Sitting still and sleeping badly.

tibble(
  Metric = c("Users Analysed", "Days of tracked activity", "Nights of sleep",
             "Median device wear rate", "Average sedentary time per day",
             "Nights under 7 hours of sleep", "Days reaching 10,000 steps",
             "Correlation: sedentary time vs sleep"),
  Value = c(
    as.character(n_users),
    comma(nrow(worn)),
    comma(nrow(sleep)),
    percent(median((worn |>  count(id, name = "d") |> 
      left_join(daily |>  distinct(id, period) |> 
        left_join(daily |>  group_by(period) |> 
          summarise(w = as.numeric(max(date) - min(date)) + 1, .groups = "drop"),
          by = "period") |>  group_by(id) |> 
        summarise(w = sum(w), .groups = "drop"), by = "id") |> 
      mutate(r = d / w))$r), 1),
    paste0(round(mean(worn$sedentary_minutes) / 60, 1), " hours"),
    percent(mean(sleep$hours_asleep < 7), 0.1),
    percent(mean(worn$total_steps >= 10000), 0.1),
    round(cor((worn |>  inner_join(sleep, by = c("id", "date")))$sedentary_minutes,
              (worn |>  inner_join(sleep, by = c("id", "date")))$hours_asleep), 2)
  )
) |>  kable(caption = "Headline figures")
Headline figures
Metric Value
Users Analysed 35
Days of tracked activity 1,235
Nights of sleep 882
Median device wear rate 63%
Average sedentary time per day 15.9 hours
Nights under 7 hours of sleep 49.1%
Days reaching 10,000 steps 34.0%
Correlation: sedentary time vs sleep -0.48

Top three recommendations: replacing the fixed 10,000-step goal with an adaptive one; Market the Bellabeat app on the sit less sleep better link; And time notifications to the two hours when users are already moving.


1. Ask : Summary of the business task

Deliverable 1: A clear Summary of the business task

Bellabeat is a high tech manufacturer of health focused products for women, founded in 2013 by Urška Sršen and Sando Mur. The product line comprises the Bellabeat app, the Leaf tracker, the Time watch, the Spring water bottle and a subscription membership.

Urška Sršen believes analysing smart device fitness data could unlock new growth. She has asked the marketing analytics team to analyse usage data from non Bellabeat smart devices, select one Bellabeat product, and produce high level recommendations for marketing strategy.

The business task

Identify how consumers actually use non-Bellabeat smart devices, and determine which of the resulting behavioural gaps Bellabeat is positioned to close through its marketing of the Bellabeat app.

Guiding questions this report answers

  1. What are some trends in smart device usage?
  2. How could these trends apply to Bellabeat customers?
  3. How could these trends help influence Bellabeat marketing strategy?

Key stakeholders

Stakeholder Role What they need from this
Urška Sršen Cofounder, Chief Creative Officer Growth opportunities grounded in evidence
Sando Mur Cofounder, executive team Analytically sound conclusions
Marketing analytics team Colleagues A reproducible basis for campaign decisions

Product selected

The Bellabeat App. It connects every device in the range, is the surface where behavioural nudges are delivered, and drives the membership subscription so insights about behaviour can be acted on there fastest and most cheaply.


2. Prepare : Description of data sourceses

Deliverable 2: A Description of all Data sources used

Source and licensing

Download the dataset on Kaggle ↗

Item Detail
Dataset FitBit Fitness Tracker Data
Made available by Mobius, via Kaggle
Licence CC0 public domain, no restriction on use
Collection Amazon Mechanical Turk survey, 12 March to 12 May 2016
Consent Participants explicitly consented to submit personal tracker data
Privacy No names, addresses or demographics; users identified by numeric ID only
Storage Downloaded and stored locally in dated subfolders; originals unmodified

Because the data is CC0 and carries no personal identifiers, there are no licensing, Privacy or security barriers to this analysis. All cleaning was done on copies; the source .csv files were never overwritten.

How the data is organised

The dataset ships as two Export folders covering consecutive months:

  • mturkfitbit_export_3.12.16-4.11.16 - 12 Mar to 11 Apr 2016
  • mturkfitbit_export_4.12.16-5.12.16 - 12 Apr to 12 May 2016

Both contain the same Measurements at four Level of details : daily, hourly, minute and second in long (Narrow) and wide formats.

Most published Analyses of this Dataset use only the second folder. Both folders share an identical dailyActivity_merged.csv schema, so this analysis combines them:

tibble(
  Scope = c("Typical single folder analysis", "This analysis (both folders)"),
  Users = c(33, n_users),
  `Days covered` = c(31, as.numeric(max(daily$date) - min(daily$date)) + 1),
  `Sleep nights` = c(410, nrow(sleep))
) |>  kable(caption = "Combining both export folders roughly doubles the evidence base")
Combining both export folders roughly doubles the evidence base
Scope Users Days covered Sleep nights
Typical single folder analysis 33 31 410
This analysis (both folders) 35 62 882

Files selected, and why

Of the 18 files available, six carry the analysis. dailySteps_merged.csv, dailyCalories_merged.csv and dailyIntensities_merged.csv were excluded as duplicates of columns already present in dailyActivity_merged.csv verified, not assumed:

steps_only <- read_csv(file.path(P2, "dailySteps_merged.csv"), show_col_types = FALSE)
activity   <- read_csv(file.path(P2, "dailyActivity_merged.csv"), show_col_types = FALSE)

check <- steps_only |>  rename(day = ActivityDay) |> 
  inner_join(activity |>  rename(day = ActivityDate), by = c("Id", "day"))

cat("User days compared:", nrow(check), "\n")
## User days compared: 578
cat("Step counts identical in every row:", all(check$StepTotal == check$TotalSteps), "\n")
## Step counts identical in every row: TRUE
File used Purpose
dailyActivity_merged Steps, distance, intensity minutes and calories per day
hourlySteps_merged Time of day activity patterns
sleepDay_merged Nightly sleep totals April period
minuteSleep_merged Aggregated to nights to extend sleep coverage into March
weightLogInfo_merged Feature adoption and manual entry Behaviour

heartrate_seconds_merged.csv was Excluded: 86 MB covering only 14 of 35 users, too small a subsample to support a finding. Minute level activity files were excluded as redundant to the hourly and daily aggregates.

Data integrity and credibility does it ROCCC?

tibble(
  Criterion = c("Reliable", "Original", "Comprehensive", "Current", "Cited"),
  Assessment = c("Weak", "Weak", "Weak", "Poor", "Good"),
  Detail = c(
    paste0(n_users, " self selected participants; no independent verification"),
    "Third party redistribution, not collected by Fitbit or Bellabeat",
    "No age, gender, height, location or health baseline recorded",
    "Collected in 2016; wearable hardware and user expectations have since changed",
    "Clearly sourced and openly licensed under CC0"
  )
) |>  kable(caption = "ROCCC assessment of the FitBit Fitness Tracker Data")
ROCCC assessment of the FitBit Fitness Tracker Data
Criterion Assessment Detail
Reliable Weak 35 self selected participants; no independent verification
Original Weak Third party redistribution, not collected by Fitbit or Bellabeat
Comprehensive Weak No age, gender, height, location or health baseline recorded
Current Poor Collected in 2016; wearable hardware and user expectations have since changed
Cited Good Clearly sourced and openly licensed under CC0

This data does not ROCCC well, and that is stated before the findings rather than after. The sample is small and self-selected, and it contains no gender field a material limitation when Analysing on behalf of a company selling exclusively to women. Every finding below is a directional signal to be validated against Bellabeat’s own data, not a conclusion to commit budget to.

Sršen suggested considering an additional dataset to address these limits. The most valuable addition would be Bellabeat’s own app telemetry, where gender, age and product line are known.


3. Process : Documentation of data cleaning

Deliverable 3: Documentation of any cleaning or manipulation of data

Tools chosen

R with the tidyverse, for three reasons: the dataset exceeds Excel’s comfortable working range at minute level over 1.3 million rows; every cleaning step is recorded as code and is therefore reproducible and auditable; and the same environment handles cleaning, statistics and visualisation without exporting between tools. This document is written in R Markdown, so every figure quoted is generated at knit time rather than typed in by hand.

Checking for errors

tibble(
  Check = c("Duplicate rows in daily activity",
            "Duplicate rows in sleep (April roll up)",
            "Missing values in daily activity",
            "Days recording zero steps",
            "Weight entries with missing body fat value"),
  Result = c(
    sum(duplicated(daily_raw)),
    sum(duplicated(sleep_apr_raw)),
    sum(is.na(daily_raw)),
    sum(!daily$is_wear_day),
    sum(is.na(weight$fat))
  )
) |>  kable(caption = "Integrity checks Run before any analysis")
Integrity checks Run before any analysis
Check Result
Duplicate rows in daily activity 0
Duplicate rows in sleep (April roll up) 3
Missing values in daily activity 22867493
Days recording zero steps 138
Weight entries with missing body fat value 94

Cleaning steps used

  1. Combined both export folders for daily activity, hourly steps and weight, after confirming identical Schemas. A period column preserves the source window for each row.
  2. Standardised column names to snake_case using janitor::clean_names().
  3. Parsed dates from text. read_csv() imports "4/12/2016" as a character string; lubridate::mdy() and mdy_hms() convert these to date objects so they can be sorted, filtered and grouped by weekday.
  4. Removed duplicate records. sleepDay_merged.csv contains three exact duplicate rows, a documented flaw in this dataset. removing duplication on id Plus date was applied to every table as a safeguard at the folder boundary.
  5. Separated non-wear days.
  6. Aggregated March minute-level sleep to nightly totals.
  7. Derived fields: weekday, total active minutes, hours asleep, minutes awake in bed, sleep efficiency, and per user device wear rate.

Handling non wear days

138 rows record zero steps alongside close to a full day of sedentary minutes. These represent a device left on a charger, not a participant who remained motionless for 24 hours:

daily |> 
  group_by(`Day type` = ifelse(is_wear_day, "Device worn", "Zero steps recorded")) |> 
  summarise(Days = n(),
            `Mean sedentary minutes` = round(mean(sedentary_minutes)),
            `Mean calories` = round(mean(calories)), .groups = "drop") |> 
  kable(caption = "Zero step days average close to 1,440 sedentary minutes, confirming non wear")
Zero step days average close to 1,440 sedentary minutes, confirming non wear
Day type Days Mean sedentary minutes Mean calories
Device worn 1235 952 2336
Zero steps recorded 138 1367 1621

These rows were excluded from all activity averages but kept for the device engagement analysis, where they are the direct evidence of how often the tracker is worn. Including them in averages would understate activity by approximately 10%.

Extending sleep coverage into March

sleepDay_merged.csv exists only in the April folder. Using it alone would draw activity from both months but sleep from one. minuteSleep_merged.csv covers March at minute level, where value is coded 1 = asleep, 2 = restless, 3 = awake, and every row represents one minute in bed. Counting rows per night yields time in bed; counting value == 1 yields minutes asleep the same two measures the April file reports.

Assumption declared: sleep crosses midnight, so grouping by calendar date would split a single night into two records. Subtracting six hours before taking the date assigns before dawn minutes to the previous night. Records under 60 Minutes were dropped as fragments rather than nights. This 6 a.m cutoff is a Judgement call; excluding March entirely reduces the evidence base from 882 to 410 nights without changing the direction or meaning of any finding.

Verifying the cleaned Data

tibble(
  Table = c("Daily activity (worn days)", "Sleep", "Hourly steps", "Weight logs"),
  Rows = c(nrow(worn), nrow(sleep), nrow(hourly), nrow(weight)),
  Users = c(n_distinct(worn$id), n_distinct(sleep$id),
            n_distinct(hourly$id), n_distinct(weight$id)),
  `Date range` = c(
    paste(format(min(worn$date), "%d %b"), "-", format(max(worn$date), "%d %b %Y")),
    paste(format(min(sleep$date), "%d %b"), "-", format(max(sleep$date), "%d %b %Y")),
    paste(format(min(as_date(hourly$ts)), "%d %b"), "-", format(max(as_date(hourly$ts)), "%d %b %Y")),
    paste(format(min(weight$date), "%d %b"), "-", format(max(weight$date), "%d %b %Y"))
  )
) |>  kable(caption = "Cleaned tables ready for Analysis")
Cleaned tables ready for Analysis
Table Rows Users Date range
Daily activity (worn days) 1235 35 12 Mar - 12 May 2016
Sleep 882 25 11 Mar - 12 May 2016
Hourly steps 46008 35 12 Mar - 12 May 2016
Weight logs 98 13 30 Mar - 12 May 2016

4. Analyze : Summary of the Analysis

Deliverable 4: An Analysis Summary

The Analysis Proceeded in four passes. Descriptive statistics established baseline activity, sleep and calorie levels against published health benchmarks. User level segmentation grouped participants by average daily steps and by device wear rate always aggregating per user before comparing users, so that participants with more logged days do not dominate the averages. Temporal aggregation examined activity by hour of day and day of week. Correlation and regression tested the relationships between steps, calories, sedentary time and sleep duration.

The central surprise: the variable most strongly associated with sleep is not step count but sedentary time, and the relationship is roughly three times stronger. This Reframes the marketing proposition from motivating exercise to reducing sitting a substantially lower bar for the user and a claim no major competitor is currently making.

sa <- worn |> inner_join(sleep, by = c("id", "date"))

tibble(
  Relationship = c("Daily steps vs calories burned",
                   "Sedentary minutes vs calories burned",
                   "Daily steps vs hours asleep",
                   "Sedentary minutes vs hours asleep"),
  `Pearson r` = c(
    round(cor(worn$total_steps, worn$calories), 3),
    round(cor(worn$sedentary_minutes, worn$calories), 3),
    round(cor(sa$total_steps, sa$hours_asleep), 3),
    round(cor(sa$sedentary_minutes, sa$hours_asleep), 3)
  ),
  n = c(nrow(worn), nrow(worn), nrow(sa), nrow(sa))
) |>  kable(caption = "Correlations tested. Association only causal direction is untested.")
Correlations tested. Association only causal direction is untested.
Relationship Pearson r n
Daily steps vs calories burned 0.563 1235
Sedentary minutes vs calories burned 0.033 1235
Daily steps vs hours asleep -0.150 603
Sedentary minutes vs hours asleep -0.483 603

5. Share : Visualisations and key Findings

Deliverable 5: Supporting Visualisations and key Findings

Finding 1 : The device comes off, and it happens often

period_len <- daily |>  group_by(period) |> 
  summarise(days = as.numeric(max(date) - min(date)) + 1, .groups = "drop")

user_window <- daily |>  distinct(id, period) |> 
  left_join(period_len, by = "period") |> 
  group_by(id) |>  summarise(window_days = sum(days), .groups = "drop")

usage <- worn |>  count(id, name = "wear_days") |> 
  left_join(user_window, by = "id") |> 
  mutate(wear_rate = wear_days / window_days,
         segment = case_when(wear_rate >= 0.80 ~ "High use (80-100%)",
                             wear_rate >= 0.50 ~ "Moderate use (50-79%)",
                             TRUE              ~ "Low use (<50%)") |> 
           factor(levels = c("High use (80-100%)", "Moderate use (50-79%)", "Low use (<50%)")))

usage_sum <- usage |>  count(segment, .drop = FALSE) |>  mutate(pct = n / sum(n))

ggplot(usage_sum, aes(reorder(segment, n), n, fill = segment)) +
  geom_col(width = .62) +
  geom_text(aes(label = paste0(n, " users (", percent(pct, 1), ")")),
            hjust = -0.08, size = 3.8, colour = SLATE) +
  coord_flip() + scale_y_continuous(expand = expansion(c(0, .3))) +
  scale_fill_manual(values = c(TEAL, MINT, CORAL), guide = "none") +
  labs(title = paste0("Median user wore the tracker on just ",
                      percent(median(usage$wear_rate), 1), " of days"),
       subtitle = "Days with any steps logged, as a share of each user's export window",
       x = NULL, y = "Number of users") + theme_bb

The Median user Logged steps on 63% of available days. No participant wore the device on 80% or more of days, and 13 wore it on fewer than half.

Why it matters: a Tracker spending a third of its life in a drawer cannot deliver on sleep, stress or cycle insight. Engagement is the binding constraint on every other feature.

Finding 2 The 10,000 step goal is a Wall, not a Target

user_avg <- worn |>  group_by(id) |> 
  summarise(avg_steps = mean(total_steps), .groups = "drop") |> 
  mutate(level = case_when(avg_steps < 5000  ~ "Sedentary (<5k)",
                           avg_steps < 7500  ~ "Low active (5-7.5k)",
                           avg_steps < 10000 ~ "Somewhat active (7.5-10k)",
                           TRUE              ~ "Active (10k+)") |> 
           factor(levels = c("Sedentary (<5k)", "Low active (5-7.5k)",
                             "Somewhat active (7.5-10k)", "Active (10k+)")))

lvl <- user_avg |>  count(level, .drop = FALSE) |>  mutate(pct = n / sum(n))

ggplot(lvl, aes(level, n, fill = level)) +
  geom_col(width = .62) +
  geom_text(aes(label = paste0(n, "\n", percent(pct, 1))),
            vjust = -0.25, size = 3.6, colour = SLATE) +
  scale_y_continuous(expand = expansion(c(0, .25))) +
  scale_fill_manual(values = c(CORAL, SAND, MINT, TEAL), guide = "none") +
  labs(title = paste0(sum(lvl$n[1:2]), " of ", sum(lvl$n),
                      " users average under 7,500 steps a day"),
       subtitle = "Users grouped by their own average daily step count",
       x = NULL, y = "Number of users") + theme_bb

Mean daily steps were 8,057 and the Median 7,623. Only 34.0% of days Reached 10,000 steps.

worn |>  group_by(weekday) |> 
  summarise(avg_steps = mean(total_steps), .groups = "drop") |> 
  ggplot(aes(weekday, avg_steps, fill = avg_steps)) +
  geom_col(width = .68) +
  geom_hline(yintercept = 10000, linetype = "dashed", colour = CORAL, linewidth = .7) +
  annotate("text", x = 1, y = 10400, label = "10,000 step goal",
           colour = CORAL, size = 3.2, hjust = 0) +
  scale_fill_gradient(low = MINT, high = TEAL, guide = "none") +
  scale_y_continuous(labels = comma, expand = expansion(c(0, .12))) +
  labs(title = "No day of the week reaches the 10,000 step goal on Average",
       subtitle = "Sunday is the least Active day", x = NULL, y = "Average steps") +
  theme_bb

Why that matters: for half the user base the default goal is unreachable. A goal missed daily stops functioning as motivation and becomes a reminder of failure.

Finding 3 : The problem is sitting, not exercising

mins <- worn |> 
  summarise(Sedentary = mean(sedentary_minutes), Light = mean(lightly_active_minutes),
            Fair = mean(fairly_active_minutes), Very = mean(very_active_minutes)) |>
  pivot_longer(everything(), names_to = "intensity", values_to = "minutes") |>
  mutate(pct = minutes / sum(minutes),
         intensity = factor(intensity, levels = c("Sedentary", "Light", "Fair", "Very")))

ggplot(mins, aes("", minutes, fill = intensity)) +
  geom_col(width = .55) +
  geom_text(aes(label = ifelse(pct > .03, paste0(intensity, "\n", round(minutes), " min"), "")),
            position = position_stack(vjust = .5), size = 3.6,
            colour = "white", fontface = "bold") +
  coord_flip() + scale_fill_manual(values = c(SLATE, MINT, SAND, CORAL)) +
  labs(title = paste0(percent(mins$pct[mins$intensity == "Sedentary"], 1),
                      " of tracked time is sedentary"),
       subtitle = "Average minutes per worn day by intensity band",
       x = NULL, y = "Minutes", fill = NULL) +
  theme_bb + theme(axis.text.y = element_blank())

Users averaged 15.9 sedentary hours per tracked day against 21.7 very Active Minutes. On 55.8% of days, combined fairly and very Active time fell under 30 minutes.

ct <- cor.test(worn$total_steps, worn$calories)
m  <- lm(calories ~ total_steps, data = worn)

ggplot(worn, aes(total_steps, calories)) +
  geom_point(alpha = .28, colour = TEAL, size = 1.5) +
  geom_smooth(method = "lm", se = TRUE, colour = CORAL, fill = SAND) +
  scale_x_continuous(labels = comma) + scale_y_continuous(labels = comma) +
  labs(title = "More steps means more calories burned - but the payoff is modest",
       subtitle = paste0("r = ", round(ct$estimate, 2), ", about ",
                         round(coef(m)[2] * 1000), " calories per additional 1,000 steps"),
       x = "Daily steps", y = "Calories burned") + theme_bb

Why that matters: the addressable opportunity is the 15.9 sedentary hours, not the 22 active minutes. Breaking up sitting is a much lower bar for the user than adding workouts.

Finding 4 : Activity clusters at two predictable peaks

by_hour <- hourly |>  group_by(hour) |> 
  summarise(avg_steps = mean(step_total), .groups = "drop")

ggplot(by_hour |>  mutate(peak = hour %in% c(12, 13, 17, 18, 19)),
       aes(factor(hour), avg_steps, fill = peak)) +
  geom_col(width = .78) +
  scale_fill_manual(values = c(`FALSE` = GREY, `TRUE` = TEAL), guide = "none") +
  labs(title = "Activity peaks at lunch and again from 5 to 7pm",
       subtitle = "Average steps per hour of day, all users pooled",
       x = "Hour of day", y = "Average steps") + theme_bb

by_hour |>  arrange(desc(avg_steps)) |>  head(5) |> 
  transmute(Hour = paste0(hour, ":00"), `Average steps` = round(avg_steps)) |> 
  kable(caption = "Five busiest Hours of the day")
Five busiest Hours of the day
Hour Average steps
19:00 555
18:00 550
12:00 534
14:00 506
17:00 500

Why that matters: notification timing is guesswork for most apps. These windows are when users are already in motion and most receptive to a prompt.

Finding 5 : Half of all nights falling short of healthy Sleep

ggplot(sleep, aes(hours_asleep)) +
  geom_histogram(binwidth = .5, fill = TEAL, colour = "white") +
  geom_vline(xintercept = 7, linetype = "dashed", colour = CORAL, linewidth = .8) +
  annotate("text", x = 7.12, y = Inf, vjust = 2, hjust = 0, colour = CORAL, size = 3.4,
           label = "7 hours = Minimum Recommended") +
  labs(title = paste0(percent(mean(sleep$hours_asleep < 7), 1),
                      " of nights fall short of 7 hours of sleep"),
       subtitle = paste0(nrow(sleep), " nights from ", n_distinct(sleep$id), " users"),
       x = "Hours asleep", y = "Nights") + theme_bb

Mean sleep was 6.68 hours, with an Average of 35.2 minutes spent awake in bed.

ggplot(sa, aes(sedentary_minutes, hours_asleep)) +
  geom_point(alpha = .3, colour = TEAL, size = 1.5) +
  geom_smooth(method = "lm", se = TRUE, colour = CORAL, fill = SAND) +
  geom_hline(yintercept = 7, linetype = "dotted", colour = SLATE) +
  labs(title = "The more inactive during the day, the less sleeping that night",
       subtitle = paste0("r = ", round(cor(sa$sedentary_minutes, sa$hours_asleep), 2),
                         " across ", nrow(sa), " Matched user days"),
       x = "Inactive minutes", y = "Hours asleep") + theme_bb

Why that matters: this is the most actionable relationship in the dataset. The proposition is not “walk more, sleep better” but “sit less, sleep better” a concrete, defensible and differentiated claim.

Finding 6 : Adoption Collapses when effort is needed

adopt <- tibble(
  feature = c("Activity (passive)", "Sleep (wear overnight)", "Weight (manual entry)"),
  users   = c(n_distinct(daily$id), n_distinct(sleep$id), n_distinct(weight$id))
) |>  mutate(pct = users / n_users)

ggplot(adopt, aes(reorder(feature, pct), pct, fill = feature)) +
  geom_col(width = .6) +
  geom_text(aes(label = paste0(users, " users (", percent(pct, 1), ")")),
            hjust = -0.08, size = 3.8, colour = SLATE) +
  coord_flip() +
  scale_y_continuous(labels = percent, limits = c(0, 1.3), expand = c(0, 0)) +
  scale_fill_manual(values = c(TEAL, SAND, CORAL), guide = "none") +
  labs(title = "Tracking drops sharply once it needs manual input",
       subtitle = "Share of users who logged each data type at least once",
       x = NULL, y = "Share of users") + theme_bb

64.3% of weight entries were typed in by hand, with a median of 2 logs per logging user Across the whole window.

Why it matters: each increment of friction costs roughly a third of the user base. Features requiring manual input will not be adopted regardless of design quality.


6. Act : Recommendations

Deliverable 6: Top high level content Recommendations

Answers to the three useful questions

1. What are some trends in smart device usage? Devices are worn inconsistently a mMedian of 63% of days, with no user exceeding 80%. Activity falls well short of public health targets: only 34.0% of days reach 10,000 steps, and 15.9 hours of the Average tracked day are sedentary. Activity concentrates at lunchtime and early evening. Sleep is short 49.1% of nights fall under seven hours and feature adoption drops sharply whenever manual input is required.

2. How could these trends apply to Bellabeat Customers? Bellabeat customers use the same categories of device for the same purposes, so the same behavioural ceilings apply. Two carry across most directly. First, wear rate caps everything: Bellabeat’s stress, sleep and the cycle features depend on consistent wear, so the Leaf’s Jewellery form factor Addresses a real constraint rather than a cosmetic one. Second, the restful sleep link maps precisely onto Bellabeat’s existing positioning around holistic wellness rather than athletic performance.

3. How could these trends help influence Bellabeat marketing strategy? They shift the message from performance to realism. Rather than competing with Fitbit and Apple on step counts and workout tracking where the data shows most users are failing Bellabeat can own the lower, more achievable and better evidenced proposition of sitting less to sleep better.

Top three recommendations

1. Replace the fixed step goal with an adaptive one

Only 34.0% of days reach 10,000 steps and 18 of 35 users average under 7,500. Set each user’s initial target from their own first week baseline and raise it incrementally.

Marketing line: “A goal that meets you where you are.”

3. Time notifications to the two peaks

Activity peaks at 12 to 2 p.m. and 5 to 7 p.m. Deliver movement prompts in those windows, the daily plan in the 7 a.m. lull, and a wind down prompt around 10 p.m This is the cheapest of the three to implement and the fastest to measure.

Marketing line: “Nudges when you’re already moving.”

Supporting Recommendations

4. Make the Leaf’s form factor the retention pitch. Wear rate is the ceiling on every other feature. Market the Leaf as “the tracker you don’t take off”, and reward day after day worn rather than steps achieved.

5. Eliminate manual logging. Weight logging reached 37% of users. Assume any hand entry feature will fail; Prioritise Spring’s automatic hydration sync and smart scale integration.

6. Position membership content around midweek. Sleep and Activity both dip midweek. Schedule coaching content for Sunday evening and Tuesday morning.


7. Limitations and next steps

tibble(
  Limitation = c("Sample size", "Gender data", "Data age", "Recruitment",
                 "Causality", "Sleep aggregation"),
  Detail = c(
    paste0(n_users, " participants too few to generalise to a consumer market"),
    "Not recorded, yet Bellabeat sells exclusively to women",
    paste0("Collected ", format(min(daily$date), "%b %Y"), " to ",
           format(max(daily$date), "%b %Y"), " nine years old"),
    "Self selected MTurk volunteers, unlikely to match Bellabeat's customer base",
    "The sedentary sleep link is an association; direction is untested",
    "March nights rely on a 6 a.m. cutoff assumption (see section 3.5)"
  )
) |>  kable(caption = "Constraints on these findings")
Constraints on these findings
Limitation Detail
Sample size 35 participants too few to generalise to a consumer market
Gender data Not recorded, yet Bellabeat sells exclusively to women
Data age Collected Mar 2016 to May 2016 nine years old
Recruitment Self selected MTurk volunteers, unlikely to match Bellabeat’s customer base
Causality The sedentary sleep link is an association; direction is untested
Sleep aggregation March nights rely on a 6 a.m. cutoff assumption (see section 3.5)

These findings should be validated before budget is committed. Recommended next steps, in order of cost:

  1. A/B test notification timing against the 12 to 2 p.m and 5 to 7 p.m windows. Cheapest to run and fastest to prove or disprove.
  2. Replicate this analysis on Bellabeat’s own app telemetry, where gender, age and product line are known the additional dataset Sršen suggested.
  3. Test the adaptive goal against the fixed 10,000 step goal, measuring 30 day Active usage rather than step count.
  4. Survey lapsed users on why the device came off, to confirm whether form factor is genuinely the binding constraint on wear rate.

Appendix

sessionInfo()
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=German_Germany.utf8  LC_CTYPE=German_Germany.utf8   
## [3] LC_MONETARY=German_Germany.utf8 LC_NUMERIC=C                   
## [5] LC_TIME=C                      
## 
## time zone: Europe/Berlin
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] knitr_1.51      scales_1.4.0    janitor_2.2.1   lubridate_1.9.5
##  [5] forcats_1.0.1   stringr_1.6.0   dplyr_1.2.1     purrr_1.2.2    
##  [9] readr_2.2.0     tidyr_1.3.2     tibble_3.3.1    ggplot2_4.0.3  
## [13] tidyverse_2.0.0
## 
## loaded via a namespace (and not attached):
##  [1] sass_0.4.10        generics_0.1.4     lattice_0.22-9     stringi_1.8.9     
##  [5] hms_1.1.4          digest_0.6.39      magrittr_2.0.5     evaluate_1.0.5    
##  [9] grid_4.6.1         timechange_0.4.0   RColorBrewer_1.1-3 fastmap_1.2.0     
## [13] Matrix_1.7-5       jsonlite_2.0.0     mgcv_1.9-4         jquerylib_0.1.4   
## [17] cli_3.6.6          rlang_1.3.0        crayon_1.5.3       splines_4.6.1     
## [21] bit64_4.8.2        withr_3.0.3        cachem_1.1.0       yaml_2.3.12       
## [25] otel_0.2.0         tools_4.6.1        parallel_4.6.1     tzdb_0.5.0        
## [29] vctrs_0.7.3        R6_2.6.1           lifecycle_1.0.5    snakecase_0.11.1  
## [33] bit_4.6.0          vroom_1.7.1        pkgconfig_2.0.3    pillar_1.11.1     
## [37] bslib_0.12.0       gtable_0.3.6       glue_1.8.1         xfun_0.60         
## [41] tidyselect_1.2.1   rstudioapi_0.19.0  farver_2.1.2       nlme_3.1-169      
## [45] htmltools_0.5.9    rmarkdown_2.31     labeling_0.4.3     compiler_4.6.1    
## [49] S7_0.2.2