diff --git a/app/services/daily_stats_data_importer.rb b/app/services/daily_stats_data_importer.rb
new file mode 100644
index 0000000..e05ddbd
--- /dev/null
+++ b/app/services/daily_stats_data_importer.rb
@@ -0,0 +1,63 @@
+require "csv"
+
+class DailyStatsDataImporter
+ def self.import(filepath, user_id)
+ new.import(filepath, user_id)
+ end
+
+ def import(filepath, user_id)
+ user = User.find(user_id)
+ read_file(filepath)
+ .map { |data| process_data(data) }
+ .map { |data| user.daily_stats.create(data) }
+ end
+
+ private
+
+ def read_file(filepath)
+ case File.extname(filepath)
+ when ".csv"
+ read_csv(filepath)
+ else
+ raise "Unknown file type"
+ end
+ end
+
+ def read_csv(filepath)
+ results = []
+ CSV.foreach(filepath, headers: true) do |row|
+ row_data = {
+ date: row["Date"],
+ data: {
+ row["Activity Name"] => row["Quantity"],
+ },
+ }
+ same_date_item = results.find do |item|
+ item[:date] == row_data[:date]
+ end
+ if same_date_item.present?
+ same_date_item[:data] = same_date_item[:data].merge(row_data[:data])
+ else
+ results << row_data
+ end
+ end
+ results
+ end
+
+ def process_data(data)
+ parse_date(data)
+ .then { |new_data| parse_values(new_data) }
+ end
+
+ def parse_date(data)
+ data[:date] = Date.parse(data[:date])
+ data
+ end
+
+ def parse_values(data)
+ data[:data].each do |key, value|
+ data[:data][key] = value.to_f
+ end
+ data
+ end
+end
diff --git a/app/views/settings/show.html.erb b/app/views/settings/show.html.erb
new file mode 100644
index 0000000..a5ba351
--- /dev/null
+++ b/app/views/settings/show.html.erb
@@ -0,0 +1,8 @@
+
+
+ <%= form_with(url: import_path, multipart: true) do |form| %>
+ <%= form.file_field :file, {accept: ".csv"} %>
+ <%= form.button "Upload Stats", {class: "btn btn-info"} %>
+ <% end %>
+
+
\ No newline at end of file
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index ef70648..ff00efc 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -186,7 +186,7 @@
# ==> Configuration for :timeoutable
# The time you want to timeout the user session without activity. After this
# time the user will be asked for credentials again. Default is 30 minutes.
- # config.timeout_in = 30.minutes
+ config.timeout_in = 2.weeks
# ==> Configuration for :lockable
# Defines which strategy will be used to lock an account.
diff --git a/config/routes.rb b/config/routes.rb
index df9826b..35fe9aa 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -6,6 +6,8 @@
root to: "users#index"
end
mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" if Rails.env.development?
+ resource :settings, only: [:show]
+ post :import, to: "settings#import"
post "/graphql", to: "graphql#execute"
devise_for :users
# For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
diff --git a/spec/features/user_adds_stats_using_nice_ui_spec.rb b/spec/features/user_adds_stats_using_nice_ui_spec.rb
index 9fe2fa6..d068930 100644
--- a/spec/features/user_adds_stats_using_nice_ui_spec.rb
+++ b/spec/features/user_adds_stats_using_nice_ui_spec.rb
@@ -1,29 +1,13 @@
require "rails_helper"
feature "User adds stats using nice UI", js: true do
- context "when a user existst with stats" do
+ context "with a user who has stats" do
before do
@user_claudia = create(:user_claudia)
- @user_claudia
- .daily_stats
- .append(
- create(
- :daily_stat,
- date: Date.parse("2015-04-01"),
- data: {situps: 100, weight: 66.6},
- user: @user_claudia,
- ),
- )
- @user_claudia
- .daily_stats
- .append(
- create(
- :daily_stat,
- date: Date.parse("2015-04-02"),
- data: {situps: 80, weight: 66.6},
- user: @user_claudia,
- ),
- )
+ @user_claudia.daily_stats.append(create(:daily_stat, date: Date.parse("2015-04-01"),
+data: {situps: 100, weight: 66.6}, user: @user_claudia,))
+ @user_claudia.daily_stats.append(create(:daily_stat, date: Date.parse("2015-04-02"),
+data: {situps: 80, weight: 66.6}, user: @user_claudia,))
end
scenario "Claudia signs in, sees her stats and updates them" do
diff --git a/spec/features/user_uploads_own_data_spec.rb b/spec/features/user_uploads_own_data_spec.rb
new file mode 100644
index 0000000..0739e1c
--- /dev/null
+++ b/spec/features/user_uploads_own_data_spec.rb
@@ -0,0 +1,71 @@
+require "rails_helper"
+
+feature "User uploads their data using rails UI", js: true do
+ context "with a user that has no stats" do
+ before do
+ @user_claudia = User.create!(
+ email: "claudia.king@automio.com",
+ password: "1password",
+ confirmed_at: DateTime.now,
+ )
+ end
+
+ scenario "Claudia signs in, sees no stats in Stats" do
+ When "Claudia signs in and views her stats in pretty UI" do
+ visit root_path
+ page.find("nav a", text: "Log in").click
+ focus_on(:form).for(user_session_path).submit(
+ "Email" => @user_claudia.email,
+ "Password" => @user_claudia.password,
+ )
+
+ page.find(".nav a[href='/app/stats']", text: "Stats").click
+ end
+
+ Then "she sees no stats" do
+ expect(
+ page
+ .find_all(".daily-stats-list .daily-stats-item")
+ .size,
+ ).to be(0)
+ end
+
+ When "she uses the Rails UI to upload a csv of stats" do
+ page.find("[data-widget-type='stats-app'] .nav-tabs a[href='/settings']", text: "Settings").click
+ import_file = generate_file_with_contents("health_sample", ".csv") do
+ <<~HEALTH_SAMPLE_CSV
+ Date,Activity Name,Quantity
+ 15/08/2021,Weight,67.1
+ 14/08/2021,Weight,66.8
+ 13/08/2021,Weight,66.5
+ 12/08/2021,Weight,66
+ 11/08/2021,Pull ups,50
+ 11/08/2021,Weight,66.5
+ 10/08/2021,Weight,67
+ 08/08/2021,Push ups,200
+ 08/08/2021,Weight,66
+ 08/08/2021,Push ups,100
+ HEALTH_SAMPLE_CSV
+ end
+ attach_file("file", import_file.path)
+ page.find("button", text: "Upload Stats").click
+ end
+
+ Then "she sees a notification that the upload is completed" do
+ wait_for do
+ page.find("p.alert [data-testid=\"message\"]").text
+ end.to eq "Upload complete"
+ end
+
+ Then "she sees the new stats under stats" do
+ page.find(".nav a[href='/app/stats']", text: "Stats").click
+
+ expect(
+ page
+ .find_all(".daily-stats-list .daily-stats-item")
+ .size,
+ ).to be(7)
+ end
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 5e3b339..d53c6d8 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -75,4 +75,7 @@
# predictable host and port for email links
Capybara.server_port = 3001
Capybara.server_host = "localhost"
+
+ # file helper can create temporary files with given content
+ config.include FileHelper
end
diff --git a/spec/services/daily_stats_data_importer_spec.rb b/spec/services/daily_stats_data_importer_spec.rb
new file mode 100644
index 0000000..e7295cc
--- /dev/null
+++ b/spec/services/daily_stats_data_importer_spec.rb
@@ -0,0 +1,89 @@
+require "rails_helper"
+
+describe DailyStatsDataImporter do
+ let(:user) { create(:user_claudia) }
+ let(:file) do
+ generate_file_with_contents("health_sample", ".csv") do
+ <<~HEALTH_SAMPLE_CSV
+ Date,Activity Name,Quantity
+ 15/08/2021,Weight,67.1
+ 14/08/2021,Weight,66.8
+ 13/08/2021,Weight,66.5
+ 12/08/2021,Weight,66
+ 11/08/2021,Pull ups,50
+ 10/08/2021,Weight,67
+ 08/08/2021,Push ups,200
+ 07/08/2021,Weight,66.4
+ 06/08/2021,Weight,66.8
+ 05/08/2021,Weight,66.6
+ HEALTH_SAMPLE_CSV
+ end
+ end
+
+ let(:file_with_repeated_dates) do
+ generate_file_with_contents("health_sample", ".csv") do
+ <<~HEALTH_SAMPLE_CSV
+ Date,Activity Name,Quantity
+ 15/08/2021,Weight,67.1
+ 14/08/2021,Weight,66.8
+ 13/08/2021,Weight,66.5
+ 12/08/2021,Weight,66
+ 11/08/2021,Pull ups,50
+ 11/08/2021,Weight,66.5
+ 10/08/2021,Weight,67
+ 08/08/2021,Push ups,200
+ 08/08/2021,Weight,66
+ 07/08/2021,Weight,66.4
+ 06/08/2021,Weight,66.8
+ 05/08/2021,Weight,66.6
+ HEALTH_SAMPLE_CSV
+ end
+ end
+
+ let(:file_with_invalid_data) do
+ generate_file_with_contents("health_sample", ".csv") do
+ <<~HEALTH_SAMPLE_CSV
+ Date,Activity Name,Quantity
+ 15/08/2021,Weight,67.1
+ 14/08/2021,Weight,66.8
+ ,Pull Ups,100
+ HEALTH_SAMPLE_CSV
+ end
+ end
+
+ let(:incorrect_file_extension) do
+ generate_file_with_contents("health_sample", ".txt") do
+ <<~HEALTH_SAMPLE_CSV
+ Date,Activity Name,Quantity
+ 15/08/2021,Weight,67.1
+ HEALTH_SAMPLE_CSV
+ end
+ end
+
+ it "Creates data for a valid file" do
+ described_class.import(file.path, user.id)
+ expect(user.daily_stats.find_by(date: "2021-08-15").data).to eq({"Weight" => 67.1})
+ end
+
+ it "Saves numerical data as Floats" do
+ described_class.import(file.path, user.id)
+ expect(user.daily_stats.find_by(date: "2021-08-15").data["Weight"]).to be_a(Float)
+ end
+
+ it "Fails gracefully when the wrong file type is provided" do
+ expect do
+ described_class.import(incorrect_file_extension.path, user.id)
+ end.to raise_error(RuntimeError, "Unknown file type")
+ end
+
+ it "Can be run idempotently" do
+ described_class.import(file.path, user.id)
+ described_class.import(file.path, user.id)
+ expect(user.daily_stats.find_by(date: "2021-08-15").data).to eq({"Weight" => 67.1})
+ end
+
+ it "Combines records from rows with the same date" do
+ described_class.import(file_with_repeated_dates.path, user.id)
+ expect(user.daily_stats.find_by(date: "2021-08-11").data).to eq({"Weight" => 66.5, "Pull ups" => 50})
+ end
+end
diff --git a/spec/support/file_helper.rb b/spec/support/file_helper.rb
new file mode 100644
index 0000000..135425e
--- /dev/null
+++ b/spec/support/file_helper.rb
@@ -0,0 +1,23 @@
+module FileHelper
+ def generate_file_with_contents(*filename)
+ file = Tempfile.new(filename)
+ content = yield
+ content = csv_lines_to_string(content) if content.is_a?(Array)
+ file.write(content)
+ file.close
+ file
+ end
+
+ private
+
+ def csv_lines_to_string(csv_lines)
+ ordered_columns = csv_lines.map(&:keys).flatten.uniq.sort
+ CSV.generate do |csv|
+ csv << ordered_columns
+ csv_lines
+ .each do |csv_line|
+ csv << ordered_columns.map { |col| csv_line[col] }
+ end
+ end
+ end
+end