Skip to content

Instantly share code, notes, and snippets.

@thbar thbar/--take-the-tour.png
Last active Jan 22, 2020

What would you like to do?

Here I'm sharing a couple of quick notes on how I implemented an onboarding tour with mock data in my SaaS app WiseCash, which essentially reduced my customer support time to zero. This is a follow-up of this tweet.

Demo: if you sign-up here (no credit card required), you will see that you can start a quick tour, with fake data, not affecting your actual user data.

How this works

The "tour" button redirects to a regular page but with a specific "tour=1" parameter ( when logged in)

The top-level controller (ApplicationController) has a way to determine if we are in tour mode or not, for both regular pages or AJAX calls:

  def tour_mode?
    (params[:tour] == '1') || (request.headers['X-Tour-Mode'] == '1')

This method is used in both controllers and views to figure out if the regular behaviour should occur, or if some "tour" behaviour must be achieved instead.

Later the app relies on that to decide to use real user data or a TourDataProvider (see code below) fake data (always up to date & relative to "today", which is important to compute dynamically since WiseCash charts are aiming at forecasting the bank account balance):

source = tour_mode? ? TourDataProvider : current_user

The TourDataProvider is a standalone class building non persisted objects dynamically, adjusted for each step of the tour (because different charts require different data to actually show something interesting).

All "destructive" actions are forbidden in tour mode, with things such as:

<% unless tour_mode? %>
<a class="hoveredit" href="#" data-bind="click: editAccount">edit</a>
<% end %>

But also in the controllers:

  before_action :forbid_in_tour_mode, except: :index

  def forbid_in_tour_mode
    return false if tour_mode?

On the front-end side, the app instructs the Javascript that we are in tour mode & setup XHR accordingly, then start hopscotch which start manually on each page:

    // save this around so we can selectively disable features
    window.tourMode = <%= tour_mode?.to_json %>;
    <% if tour_mode? %>
    Tour.init(<%= Integer(params[:step] || 0) %>);
    <% end %>
class @Tour
  @init: (step = 0) =>
    # pass the tour flag for all ajax requests on our domain
    $.ajaxPrefilter (options, originalOptions, xhr) =>
      if options.type != 'GET'
        alert("Data is read-only during this tour. Come back later!")
      if !options.crossDomain
        xhr.setRequestHeader('X-Tour-Mode', '1')
        xhr.setRequestHeader('X-Tour-DataSet', @tourDataSetFor(step))
    $(document).ready () =>
      hopscotch.startTour(Tour.tour(), step)

That's the gist of it. Feel free to ask questions on the gist, happy to give more insights.

module TourDataProvider
extend self
def bank_account_balance
def fiscal_year_start_date
def yearly_income_goal
def entries(tour_data_set)
case tour_data_set
when 'burn-chart' then data_set_burn_chart
when 'dashboard' then data_set_dashboard
when 'yearly-income' then data_yearly_income
else fail "Unsupported tour data set #{tour_data_set}"
def accounts_with_tags(tour_data_set)
entries(tour_data_set).map(&:account) do |account|
{ name: account }
def new_entry(kind, due_date, amount, account, description, status = 'to_be_paid')
entry =
entry.due_date = due_date
entry.kind = kind
entry.amount = amount
entry.account = account
entry.description = description
entry.status = status
def data_yearly_income
start = fiscal_year_start_date
[3.weeks, 4250, 'Acme Corp', 'Project X, 1st iteration'],
[5.weeks, 3200, 'Acme Corp', 'Project X, 2nd iteration'],
[11.weeks, 2000, 'Super Corp', 'Retainer Agreement'],
[15.weeks, 5370, 'Acme Corp', 'Project X, 3rd iteration'],
[17.weeks, 7000, 'Super Corp'],
[21.weeks, 4000, 'Super Corp'],
[24.weeks, 3650, 'Acme Corp'],
[28.weeks, 3200, 'Acme Corp'],
[32.weeks, 1000, 'Super Corp'],
[36.weeks, 7300, 'Super Corp'],
[40.weeks, 1720, 'Acme Corp']
].map.with_index do |x, i|
status = i > 7 ? 'to_be_paid' : 'paid'
new_entry('income', start + x[0], (x[1] * yearly_income_goal) / 50_000, x[2], x[3] || '', status)
def data_set_dashboard
data = data_set_burn_chart
# add one overdue
data << new_entry('expense', -, 2750, 'Accountant', 'Yearly fees')
def data_set_burn_chart
today =
e = []
# a bit of recurring income, 3 months of work
e << new_entry('income', today + 20.days, 5700, 'Acme Corp', 'Project X, 1st iteration')
e << new_entry('income', today + 1.month + 20.days, 5700, 'Acme Corp', 'Project X, 2nd iteration')
e << new_entry('income', today + 2.month + 20.days, 11700, 'Acme Corp', 'Project X, 3rd iteration')
# quarterly taxes
e << new_entry('expense', today + 3.months, 3500, 'Taxes', 'Quarterly taxes')
e << new_entry('expense', today + 6.months, 3500, 'Taxes', 'Quarterly taxes')
e << new_entry('expense', today + 9.months, 3500, 'Taxes', 'Quarterly taxes')
e << new_entry('expense', today + 12.months, 3500, 'Taxes', 'Quarterly taxes')
# monthly stuff
12.times do |i|
e << new_entry('expense', today + 10.days + i.months, 2500, 'Salary John', '')
e << new_entry('expense', today + 10.days + i.months, 2500, 'Salary Sarah', '')
e << new_entry('expense', today + 25.days + i.months, 350, 'Amazon EC2', '')
e << new_entry('expense', today + 25.days + i.months, 700, 'PixelArt LTD', 'Outsourced web-design work')
# retainer agreement
e << new_entry('income', today + 5.days + i.months, 1200, 'SuperCorp', 'Retainer agreement')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.