diff --git a/Gemfile b/Gemfile index 6e24fc4..e2679fa 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,7 @@ gem "polaris_view_components" gem "chartkick" gem "groupdate" gem "convenient_grouper" +gem "prophet-rb" # Importing gem "csvreader" diff --git a/Gemfile.lock b/Gemfile.lock index b940609..9ce5fab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,7 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) chartkick (5.0.4) + cmdstan (0.2.2) coderay (1.1.3) concurrent-ruby (1.2.2) connection_pool (2.4.1) @@ -190,6 +191,7 @@ GEM racc (~> 1.4) nokogiri (1.15.4-x86_64-linux) racc (~> 1.4) + numo-narray (0.9.2.1) orm_adapter (0.5.0) parallel (1.23.0) parser (3.2.2.3) @@ -199,6 +201,10 @@ GEM polaris_view_components (1.1.0) rails (>= 5.0.0) view_component (>= 3.0.0, < 4.0.0) + prophet-rb (0.5.0) + cmdstan (>= 0.2) + numo-narray (>= 0.9.1.7) + rover-df pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) @@ -256,6 +262,8 @@ GEM actionpack (>= 5.2) railties (>= 5.2) rexml (3.2.6) + rover-df (0.3.4) + numo-narray (>= 0.9.1.9) rubocop (1.56.1) base64 (~> 0.1.1) json (~> 2.3) @@ -379,6 +387,7 @@ DEPENDENCIES letter_opener pg (~> 1.1) polaris_view_components + prophet-rb pry-rails puma (~> 6.0) rack-timeout diff --git a/app/controllers/imports/globes_controller.rb b/app/controllers/imports/globes_controller.rb index 07354a4..c6acd40 100644 --- a/app/controllers/imports/globes_controller.rb +++ b/app/controllers/imports/globes_controller.rb @@ -2,9 +2,19 @@ class Imports::GlobesController < ApplicationController before_action :authenticate_user! def show - payments = current_user.payments.pluck(:shop_country, :charge_type).last(30) - globe_data = payments.map { |c| {countryCode: c[0], reverse: c[1] == "refund"} } + @import = current_user.imports.find(params[:import_id]) render json: globe_data end + + private + + def globe_data + payments = @import.payments.pluck(:shop_country, :charge_type).last(80) + globe_data = [] + payments.each do |c| + globe_data << {countryCode: c[0], reverse: c[1] == "refund"} + end + globe_data + end end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 93264a7..f9184fd 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -15,9 +15,12 @@ def new end def create - @import = current_user.imports.new(source: Import.sources[:csv_file], **import_params) - @import.user.count_usage_charges_as_recurring = import_params.dig(:user_attributes, :count_usage_charges_as_recurring) + @import = current_user.imports.new( + source: Import.sources[:csv_file], + **import_params.except(:user_attributes) + ) if @import.save + save_user_attributes redirect_to @import, notice: "Import successfully created." else flash.now[:alert] = "Import failed to create." @@ -40,7 +43,13 @@ def import_params params.require(:import).permit( :import_type, :payouts_file, - user_attributes: [:count_usage_charges_as_recurring] + user_attributes: [:id, :count_usage_charges_as_recurring] + ) + end + + def save_user_attributes + current_user.update( + count_usage_charges_as_recurring: import_params.dig(:user_attributes, :count_usage_charges_as_recurring) ) end diff --git a/app/controllers/partner_api_credentials_controller.rb b/app/controllers/partner_api_credentials_controller.rb index 142f123..829033e 100644 --- a/app/controllers/partner_api_credentials_controller.rb +++ b/app/controllers/partner_api_credentials_controller.rb @@ -15,9 +15,11 @@ def edit end def create - @partner_api_credential = current_user.build_partner_api_credential(partner_api_credential_params) - + @partner_api_credential = current_user.build_partner_api_credential( + partner_api_credential_params.except(:user_attributes) + ) if @partner_api_credential.save + save_user_attributes redirect_to edit_partner_api_credential_path(@partner_api_credential), notice: "Partner api credential was successfully created." else render :new, status: :unprocessable_entity, notice: "Partner api credential was not created." @@ -51,12 +53,17 @@ def add_status_messsage_error @partner_api_credential.errors.add(:base, @partner_api_credential.status_message) end - # Only allow a list of trusted parameters through. + def save_user_attributes + current_user.update( + count_usage_charges_as_recurring: partner_api_credential_params.dig(:user_attributes, :count_usage_charges_as_recurring) + ) + end + def partner_api_credential_params params.require(:partner_api_credential).permit( :access_token, :organization_id, - user_attributes: [:count_usage_charges_as_recurring] + user_attributes: [:id, :count_usage_charges_as_recurring] ) end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..c6f4d79 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,17 @@ +class UsersController < ApplicationController + before_action :authenticate_user! + + def update + if current_user.update(user_params) + redirect_to request.referrer, status: :see_other + else + redirect_to request.referrer, alert: "Error saving user!", status: :see_other + end + end + + private + + def user_params + params.require(:user).permit(:show_forecasts) + end +end diff --git a/app/helpers/metrics_helper.rb b/app/helpers/metrics_helper.rb index d49f402..f9065d3 100644 --- a/app/helpers/metrics_helper.rb +++ b/app/helpers/metrics_helper.rb @@ -80,6 +80,7 @@ def metrics_chart_options download: true, library: { pointSize: 6, + isStacked: false, backgroundColor: "transparent", animation: { startup: true, @@ -87,7 +88,7 @@ def metrics_chart_options easing: "inAndOut" }, lineWidth: 3, - colors: ["#5912D5", "rgba(89, 18, 213, 0.2)"], + colors: ["#5912D5", "#5912D5"], explorer: { keepInBounds: true, axis: "horizontal", @@ -101,10 +102,24 @@ def metrics_chart_options color: "#F1F2F4" } }, + timeline: { + tooltipDateFormat: "MMM d, yyyy" + }, + hAxis: { + format: "MMM d, y" + }, chartArea: { width: "100%", height: "80%", left: "5%" + }, + focusTarget: "category", + series: { + "1": { + visibleInLegend: false, + lineWidth: 0, + areaOpacity: 0 + } } } } diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js index 5b370bd..d5b3224 100644 --- a/app/javascript/controllers/form_controller.js +++ b/app/javascript/controllers/form_controller.js @@ -4,7 +4,7 @@ import DirtyForm from '../libraries/dirty-form' export default class extends Controller { static targets = ['submitButton'] static values = { - showSaveBar: { type: Boolean, default: true }, + showSaveBar: { type: Boolean, default: false }, submitButtonTarget: String } diff --git a/app/javascript/controllers/globe_controller.js b/app/javascript/controllers/globe_controller.js index 14f7404..04df72c 100644 --- a/app/javascript/controllers/globe_controller.js +++ b/app/javascript/controllers/globe_controller.js @@ -32,7 +32,7 @@ const ARC_ALTITUDES = [0.5, 0.55, 0.55, 0.6, 0.6]; const ARC_DASH_LENGTHS = [1.5, 1.8, 1.9, 1.9, 2, 2.2]; const ARC_SPEEDS = [0.6, 0.65, 0.75, 0.95, 1]; -const ARCS_AT_ONCE = 5; +const ARCS_AT_ONCE = 8; const ARC_EMIT_DELAY = 300; export default class extends Controller { @@ -295,7 +295,7 @@ export default class extends Controller { controls.rotateSpeed = 0.2; controls.zoomSpeed = 0.2; controls.autoRotate = true; - controls.autoRotateSpeed = 0.35; + controls.autoRotateSpeed = 0.25; } // SETUP CAMERA diff --git a/app/jobs/calculate_metrics_job.rb b/app/jobs/calculate_metrics_job.rb index 7021547..75486d3 100644 --- a/app/jobs/calculate_metrics_job.rb +++ b/app/jobs/calculate_metrics_job.rb @@ -5,7 +5,7 @@ class CalculateMetricsJob < ApplicationJob def perform(import:) import.calculate rescue => e - import&.failed! + import&.fail raise e end end diff --git a/app/jobs/import_payments_job.rb b/app/jobs/import_payments_job.rb index 499685f..b862e3c 100644 --- a/app/jobs/import_payments_job.rb +++ b/app/jobs/import_payments_job.rb @@ -5,7 +5,7 @@ class ImportPaymentsJob < ApplicationJob def perform(import:) import.import rescue => e - import&.failed! + import&.fail raise e end end diff --git a/app/models/import.rb b/app/models/import.rb index 9936bf4..b03fe3a 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -43,7 +43,7 @@ def schedule def import importing! - Import::Payments.new(import: self).import! + Import::PaymentsProcessor.new(import: self).import! imported end @@ -53,14 +53,42 @@ def imported def calculate calculating! - Import::Metrics.new(import: self).calculate! + Import::MetricsProcessor.new(import: self).calculate! completed! end + def fail + failed! + payments.delete_all + metrics.delete_all + end + def source_adaptor csv_file_source? ? Import::Adaptor::CsvFile : Import::Adaptor::ShopifyPaymentsApi end + def import_payments_after_date + max_allowed_ago = source_adaptor.const_get(:MAX_HISTORY).ago + if user.newest_metric_date.to_i < max_allowed_ago.to_i + max_allowed_ago + else + user.newest_metric_date + end + end + + def import_metrics_after_date + if user.newest_metric_date.present? + user.newest_metric_date + 1.day + else + user.payments.minimum("payment_date") + end + end + + def import_metrics_before_date + # Don't include the latest day, because it may not be complete + user.payments.maximum(:payment_date) - 1.day + end + private def broadcast_details_update diff --git a/app/models/import/adaptor/csv_file.rb b/app/models/import/adaptor/csv_file.rb index fc024ac..9b36a6f 100644 --- a/app/models/import/adaptor/csv_file.rb +++ b/app/models/import/adaptor/csv_file.rb @@ -2,10 +2,10 @@ require "csvreader" class Import::Adaptor::CsvFile - BATCH_SIZE = 500 + BATCH_SIZE = 1000 + MAX_HISTORY = 4.years CSV_READER_OPTIONS = { - converters: :all, header_converters: :symbol, encoding: "UTF-8" }.freeze @@ -57,11 +57,12 @@ class Import::Adaptor::CsvFile ] }.freeze - def initialize(import:, created_at_min:) + def initialize(import:, import_payments_after_date:) @import = import - @created_at_min = created_at_min + @import_payments_after_date = import_payments_after_date @temp_files = {} + @prepared_csv_file = prepared_csv_file end def fetch_payments @@ -75,9 +76,9 @@ def batch_size private def stream_payments(main_enum) - CsvHashReader.foreach(prepared_csv_file, **CSV_READER_OPTIONS) do |csv_row| + CsvHashReader.foreach(@prepared_csv_file, **CSV_READER_OPTIONS) do |csv_row| parsed_row = parse(csv_row) - break if parsed_row[:payment_date] <= @created_at_min + break if parsed_row[:payment_date] <= @import_payments_after_date main_enum.yield parsed_row end @@ -122,6 +123,8 @@ def shop_country(csv_row) end def prepared_csv_file + return @prepared_csv_file if @prepared_csv_file + file = @import.payouts_file if zipped?(file.content_type) extracted_zip_file(ActiveStorage::Blob.service.path_for(file.key)) diff --git a/app/models/import/adaptor/shopify_payments_api.rb b/app/models/import/adaptor/shopify_payments_api.rb index 468758b..9b05dc3 100644 --- a/app/models/import/adaptor/shopify_payments_api.rb +++ b/app/models/import/adaptor/shopify_payments_api.rb @@ -5,8 +5,9 @@ class Import::Adaptor::ShopifyPaymentsApi include ShopifyPartnerAPI - THROTTLE_MIN_TIME_PER_CALL = 0.3 BATCH_SIZE = 100 + MAX_HISTORY = 7.days + THROTTLE_MIN_TIME_PER_CALL = 0.3.seconds API_REVENUE_TYPES = { "recurring_revenue" => [ @@ -36,11 +37,11 @@ class Import::Adaptor::ShopifyPaymentsApi ] }.freeze - def initialize(import:, created_at_min:) + def initialize(import:, import_payments_after_date:) @import = import - @created_at_min = created_at_min.strftime("%Y-%m-%dT%H:%M:%S.%L%z") + @import_payments_after_date = import_payments_after_date.strftime("%Y-%m-%dT%H:%M:%S.%L%z") - @context = @import.partner_api_credential.context + @context = import.partner_api_credential.context @cursor = "" @throttle_start_time = Time.zone.now end @@ -61,7 +62,7 @@ def stream_payments(main_enum) while has_next_page @throttle_start_time = throttle(@throttle_start_time) - results = fetch_from_api(@cursor, @created_at_min) + results = fetch_from_api(@cursor) break if results.data.nil? transactions = results.data.transactions.edges @@ -74,10 +75,10 @@ def stream_payments(main_enum) end end - def fetch_from_api + def fetch_from_api(cursor) results = ShopifyPartnerAPI.client.query( Graphql::TransactionsQuery, - variables: {createdAtMin: @created_at_min, cursor: @cursor, first: batch_size}, + variables: {cursor: cursor, createdAtMin: @import_payments_after_date, first: batch_size}, context: @context ) handle_error(results.errors) if results.errors.any? diff --git a/app/models/import/metrics.rb b/app/models/import/metrics_processor.rb similarity index 68% rename from app/models/import/metrics.rb rename to app/models/import/metrics_processor.rb index b389e78..0cfd536 100644 --- a/app/models/import/metrics.rb +++ b/app/models/import/metrics_processor.rb @@ -1,15 +1,16 @@ -class Import::Metrics +class Import::MetricsProcessor def initialize(import:) @import = import - @user = @import.user - @import_from, @import_to = import_dates + @user = import.user + @import_from = import.import_metrics_after_date + @import_to = import.import_metrics_before_date end def calculate! return if @import_from.blank? || @import_to.blank? calculate_new_metrics rescue => error - @import&.failed! + @import&.fail raise error end @@ -32,7 +33,7 @@ def calculate_new_metrics metrics << new_metric_from(calculator: calculator) if calculator.has_metrics? end end - Metric.import!(metrics.flatten.compact, validate: false, no_returning: true) + Metric.import!(metrics, validate: false, no_returning: true) if metrics.present? @import.touch end end @@ -42,8 +43,9 @@ def app_titles_for(date:, charge_type:) end def new_metric_from(calculator:) - @user.metrics.new( - import: @import, + { + user_id: @user.id, + import_id: @import.id, metric_date: calculator.date, charge_type: calculator.charge_type, app_title: calculator.app_title, @@ -57,19 +59,6 @@ def new_metric_from(calculator:) lifetime_value: calculator.lifetime_value, repeat_customers: calculator.repeat_customers, repeat_vs_new_customers: calculator.repeat_vs_new_customers - ) - end - - def import_dates - # TODO: Returns dates for all payments, instead of just this Imports payments, because of partial dates. - latest_calculated_metric = @user.metrics.order("metric_date").last - import_from = if latest_calculated_metric.present? - latest_calculated_metric.metric_date + 1.day - else - @user.payments.minimum("payment_date") - end - last_imported_payment = @user.payments.maximum(:payment_date) - import_to = last_imported_payment - 1.day - [import_from, import_to] + } end end diff --git a/app/models/import/payments.rb b/app/models/import/payments_processor.rb similarity index 55% rename from app/models/import/payments.rb rename to app/models/import/payments_processor.rb index 6ac8d2a..4b8996f 100644 --- a/app/models/import/payments.rb +++ b/app/models/import/payments_processor.rb @@ -1,16 +1,16 @@ -class Import::Payments +class Import::PaymentsProcessor def initialize(import:) @import = import - @user = @import.user - @created_at_min = @user.calculate_from_date - @source_adaptor = @import.source_adaptor.new(import: @import, created_at_min: @created_at_min) + @user = import.user + @import_payments_after_date = import.import_payments_after_date + @source_adaptor = import.source_adaptor.new(import: import, import_payments_after_date: @import_payments_after_date) end def import! - @user.clear_old_payments! + @user.clear_old_payments!(after: @import_payments_after_date) import_new_payments rescue => error - @import&.failed! + @import&.fail raise error end @@ -18,38 +18,39 @@ def import! def import_new_payments fetched_payments.each_slice(@source_adaptor.batch_size) do |batch| - payments = batch.map do |transaction| - break if transaction[:payment_date] <= @created_at_min + payments = [] + batch.each do |transaction| + next if transaction[:payment_date] <= @import_payments_after_date next if transaction[:charge_type].nil? next if transaction[:shop].nil? next if transaction[:revenue].zero? - new_payment(transaction) - end.compact + payments << new_payment(transaction) + end - Payment.import!(payments, validate: false, no_returning: true) + Payment.import!(payments.compact, validate: false, no_returning: true) if payments.present? @import.touch end end - private - def fetched_payments @source_adaptor.fetch_payments end def new_payment(payment) payment[:charge_type] = adjust_usage_charge_type(payment) if payment[:charge_type] == "usage_revenue" - - @user.payments.new( - import: @import, + # Note to self: Do not refactor to payment.new objects + # It grows memory like crazy when processing large files + { + user_id: @user.id, + import_id: @import.id, payment_date: payment[:payment_date], charge_type: payment[:charge_type], revenue: payment[:revenue], app_title: payment[:app_title], shop: payment[:shop], shop_country: payment[:shop_country] - ) + } end def adjust_usage_charge_type(charge_type) diff --git a/app/models/metric.rb b/app/models/metric.rb index 97857c6..0ac71ad 100644 --- a/app/models/metric.rb +++ b/app/models/metric.rb @@ -1,8 +1,10 @@ +require "prophet-rb" + class Metric < ApplicationRecord belongs_to :user belongs_to :import - PERIODS = [7, 28, 29, 30, 31, 90, 180, 365].freeze + PERIODS = [1, 7, 28, 29, 30, 31, 90, 180, 365].freeze PERIODS_AGO = [1, 2, 3, 6, 12].freeze CHARGE_TYPES = ["recurring_revenue", "onetime_revenue", "affiliate_revenue", "refund"].freeze @@ -50,6 +52,13 @@ def chart_data(date, period, calculation, column) metrics.sort_by { |h| h[0].to_datetime } end + def forecast_for_chart_data(chart_data) + Prophet.forecast(chart_data, count: 3) + rescue ArgumentError + # Forecasts require a minimum of 10 data points + [] + end + # Build a hash of dates containing date ranges, # for each period between the first date and the date selected def group_options(date, first_date, period) diff --git a/app/models/metric/tile_presenter.rb b/app/models/metric/tile_presenter.rb index a6aec8d..9f9f3b9 100644 --- a/app/models/metric/tile_presenter.rb +++ b/app/models/metric/tile_presenter.rb @@ -42,7 +42,32 @@ def period_ago_change(period_ago) end def chart_data - metrics = @filter.user_metrics_by_app.by_optional_charge_type(@charge_type) - metrics.chart_data(@filter.date, @filter.period, @calculation, @column) + chart_data = basic_chart_data + + if @filter.show_forecasts? + forecast_data = forecast_chart_data(chart_data) + return chart_data if forecast_data[:data].empty? + chart_data << forecast_data + end + chart_data + end + + private + + def basic_chart_data + metrics_chart = fetch_metrics_chart + [{name: @display, data: metrics_chart}] + end + + def forecast_chart_data(chart_data) + forecast_data = Metric.forecast_for_chart_data(fetch_metrics_chart) + {name: "Forecast", data: forecast_data} + end + + def fetch_metrics_chart + @fetch_metrics_chart ||= begin + metrics = @filter.user_metrics_by_app.by_optional_charge_type(@charge_type) + metrics.chart_data(@filter.date, @filter.period, @calculation, @column).to_h + end end end diff --git a/app/models/metric/tiles_filter.rb b/app/models/metric/tiles_filter.rb index db94dff..2a9b2f0 100644 --- a/app/models/metric/tiles_filter.rb +++ b/app/models/metric/tiles_filter.rb @@ -10,7 +10,7 @@ def initialize(user:, params:) attr_reader :user, :date, :charge_type, :chart, :period, :app - delegate :oldest_metric_date, :newest_metric_date_or_today, to: :user + delegate :show_forecasts?, :oldest_metric_date, :newest_metric_date_or_today, to: :user def app_titles @user.app_titles(@charge_type) @@ -53,7 +53,7 @@ def to_param private def previous_date - @date - @period.days + 1 + @date - @period.days end def tiles_presenter diff --git a/app/models/partner_api_credential.rb b/app/models/partner_api_credential.rb index 769b68f..c109e34 100644 --- a/app/models/partner_api_credential.rb +++ b/app/models/partner_api_credential.rb @@ -21,6 +21,8 @@ class PartnerApiCredential < ApplicationRecord validates :status, presence: true, inclusion: {in: statuses.keys} validate :credentials_have_access, on: [:create, :update] + accepts_nested_attributes_for :user, update_only: true + def context { access_token: access_token, diff --git a/app/models/payment.rb b/app/models/payment.rb index 33e1521..bc0bde6 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -1,5 +1,4 @@ class Payment < ApplicationRecord - YEARS_TO_IMPORT = 4.years.freeze UNKNOWN_APP_TITLE = "Unknown".freeze belongs_to :user @@ -9,9 +8,5 @@ class << self def by_optional_app_title(app_title) app_title.blank? ? all : where(app_title: app_title) end - - def default_start_date - YEARS_TO_IMPORT.ago.to_date - end end end diff --git a/app/models/user.rb b/app/models/user.rb index 8ffef7f..eb107bc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,12 +29,8 @@ def oldest_metric_date metrics.minimum("metric_date") end - def calculate_from_date - @calculate_from_date ||= newest_metric_date.presence || Payment.default_start_date - end - - def clear_old_payments! - payments.where("payment_date > ?", calculate_from_date).delete_all + def clear_old_payments!(after:) + payments.where("payment_date > ?", after).delete_all end # TODO: DRY the following methods up diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb index 36e6857..e23adbb 100644 --- a/app/views/imports/_form.html.erb +++ b/app/views/imports/_form.html.erb @@ -3,8 +3,7 @@ model: import, builder: Polaris::FormBuilder, data: { - controller: "form", - form_show_save_bar_value: false + controller: "form" } ) do |form| %> @@ -32,6 +31,9 @@ label_hidden: false, accept: Import::ACCEPTED_FILE_TYPES.join(","), multiple: false, + data: { + action: "ondrop@window->form#markAsDirty" + } ) %> <% end %> diff --git a/app/views/imports/_table.html.erb b/app/views/imports/_table.html.erb index 1ceb62a..1f828fd 100644 --- a/app/views/imports/_table.html.erb +++ b/app/views/imports/_table.html.erb @@ -3,7 +3,7 @@ <% table.with_column(t('imports.imported_at', date: nil)) do |import| %>
<%= polaris_link(url: import_path(import), monochrome: true, no_underline: true) do %> - <%= polaris_text_style(variation: :strong) { import.created_at.to_fs(:long) }%> + <%= polaris_text_style(variation: :strong) { import.created_at.to_fs(:short) }%> <% end %>

<% end %> diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index be0aad0..fd618fd 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -6,12 +6,23 @@ <% page.with_primary_action(url: new_import_path, disabled: @imports.in_progress.any?) { "New import" } %> <% page.with_action_group( - title: t("actions.more_actions"), - actions: [ - { content: t("rename_apps.rename"), url: rename_apps_path }, - { content: t("actions.delete_all", resource: resource_name_for(Import, true)), url: destroy_all_imports_path, data: { turbo_method: :delete, turbo_confirm: t("imports.confirm_destroy") }, destructive: true } - ] - ) %> + title: t("actions.more_actions"), + actions: [ + { + content: t("rename_apps.rename"), + url: rename_apps_path + }, + { + content: t("actions.delete_all", resource: resource_name_for(Import, true)), + destructive: true, + data: { + controller: "polaris", + target: "#destroy-modal", + action: "polaris#openModal" + } + } + ] + ) %> <% if @imports.any? %> @@ -30,3 +41,11 @@ <%= render "shared/empty_state", resource: Import %> <% end %> <% end %> + +<%= render "modals/destroy", + id: "destroy-modal", + url: destroy_all_imports_path, + title: t("actions.delete", resource: resource_name_for(Import, true)) + "?", + message: t("imports.confirm_destroy"), + primary_action_text: t("actions.delete", resource: resource_name_for(Import, true)) +%> diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index 55cae6c..95681ad 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -9,12 +9,12 @@ secondary_actions: [ { content: t("actions.delete", resource: resource_name_for(Import)), - url: @import, destructive: true, data: { - turbo_method: :delete, - turbo_confirm: t("imports.confirm_destroy") - }, + controller: "polaris", + target: "#destroy-modal", + action: "polaris#openModal" + } } ], ) do |page| %> @@ -41,3 +41,11 @@ globe_target: "container", globe_fetch_url_value: import_globe_path(@import), } %> + +<%= render "modals/destroy", + id: "destroy-modal", + url: @import, + title: t("actions.delete", resource: resource_name_for(Import)) + "?", + message: t("imports.confirm_destroy"), + primary_action_text: t("actions.delete", resource: resource_name_for(Import)) +%> diff --git a/app/views/metrics/_forecasts_form.html.erb b/app/views/metrics/_forecasts_form.html.erb new file mode 100644 index 0000000..3b82f50 --- /dev/null +++ b/app/views/metrics/_forecasts_form.html.erb @@ -0,0 +1,20 @@ + <%= form_with( + model: user, + builder: Polaris::FormBuilder, + data: { + controller: "submittable", + submittable_target: "form", + action: "change->submittable#submit" + } + ) do |form| %> + + <%= polaris_tooltip(text: t("user.show_forecasts_help_text")) do %> + <%= form.polaris_check_box( + :show_forecasts, + checked: user.show_forecasts?, + label: t('user.show_forecasts'), + ) %> + + <% end %> + +<% end %> diff --git a/app/views/metrics/show.html.erb b/app/views/metrics/show.html.erb index 061db20..5190be9 100644 --- a/app/views/metrics/show.html.erb +++ b/app/views/metrics/show.html.erb @@ -7,12 +7,24 @@ <%= turbo_frame_tag :metrics, data: { controller: "chartkick", - action: "turbo:load@window->chartkick#createChart" + action: "turbo:load@window->chartkick#createChart turbo:frame-load->chartkick#createChart" } do %> <%= polaris_vertical_stack(gap: "5") do %> - <%= render "filter", filter: @filter, app_titles: @app_titles %> + <%= polaris_stack(alignment: :center) do |stack| %> + + <% stack.with_item(fill:true) do %> + <%= render "filter", filter: @filter, app_titles: @app_titles %> + <% end %> + + <% stack.with_item do %> + <%= render "forecasts_form", user: current_user %> + <% end %> + + <% end %> + + <% if @filter.has_metrics? %> diff --git a/app/views/modals/_destroy.html.erb b/app/views/modals/_destroy.html.erb new file mode 100644 index 0000000..e933523 --- /dev/null +++ b/app/views/modals/_destroy.html.erb @@ -0,0 +1,15 @@ +<%= polaris_modal(id: id, title: title) do |modal| %> + + <%= polaris_text_container do %> +

<%= message %>

+ <% end %> + + <% modal.with_primary_action( + destructive: true, + url: url, + data: {turbo_method: :delete} + ) { primary_action_text } %> + + <% modal.with_secondary_action(data: {action: "polaris-modal#close"}) { t("actions.cancel") } %> + +<% end %> diff --git a/app/views/partner_api_credentials/_form.html.erb b/app/views/partner_api_credentials/_form.html.erb index a8e0f66..6fc8ceb 100644 --- a/app/views/partner_api_credentials/_form.html.erb +++ b/app/views/partner_api_credentials/_form.html.erb @@ -3,8 +3,7 @@ model: partner_api_credential, builder: Polaris::FormBuilder, data: { - controller: "form", - form_show_save_bar_value: false + controller: "form" } ) do |form| %> diff --git a/app/views/partner_api_credentials/edit.html.erb b/app/views/partner_api_credentials/edit.html.erb index 41592dc..c89044b 100644 --- a/app/views/partner_api_credentials/edit.html.erb +++ b/app/views/partner_api_credentials/edit.html.erb @@ -4,15 +4,16 @@ narrow_width: true, title: t(".title"), subtitle: t(".subtitle"), + back_url: imports_path, secondary_actions: [ { content: t("actions.delete", resource: nil), - url: partner_api_credential_path, destructive: true, data: { - turbo_method: :delete, - turbo_confirm: t(".confirm_destroy") - }, + controller: "polaris", + target: "#destroy-modal", + action: "polaris#openModal" + } } ], ) do |page| %> @@ -24,3 +25,11 @@ <%= render "form", partner_api_credential: @partner_api_credential %> <% end %> + +<%= render "modals/destroy", + id: "destroy-modal", + url: partner_api_credential_path, + title: t("actions.delete", resource: resource_name_for(PartnerApiCredential, true)) + "?", + message: t(".confirm_destroy"), + primary_action_text: t("actions.delete", resource: nil) +%> diff --git a/app/views/partner_api_credentials/new.html.erb b/app/views/partner_api_credentials/new.html.erb index f8ae14a..5dc8417 100644 --- a/app/views/partner_api_credentials/new.html.erb +++ b/app/views/partner_api_credentials/new.html.erb @@ -3,7 +3,8 @@ <%= polaris_page( narrow_width: true, title: t(".title"), - subtitle: t(".subtitle", instructions_link: link_to("wiki article for instructions", "https://github.com/forsbergplustwo/partner-metrics/wiki/How-to-create-your-Shopify-Partner-API-credentials", target: "_blank")).html_safe + subtitle: t(".subtitle", instructions_link: link_to("wiki article for instructions", "https://github.com/forsbergplustwo/partner-metrics/wiki/How-to-create-your-Shopify-Partner-API-credentials", target: "_blank")).html_safe, + back_url: request.referer, ) do |page| %> <% page.with_title_metadata do %> diff --git a/app/views/summarys/_monthly.html.erb b/app/views/summarys/_monthly.html.erb index 38cf174..61775ed 100644 --- a/app/views/summarys/_monthly.html.erb +++ b/app/views/summarys/_monthly.html.erb @@ -1,9 +1,7 @@ <%= polaris_index_table(rows) do |table| %> <% table.with_column(t('.date', date: nil)) do |row| %> -
- <%= row[0].strftime('%b %Y') %> -
+ <%= row[0].strftime('%b %Y') %> <% end %> <% table.with_column(t('.payments')) do |row| %> diff --git a/app/views/summarys/_shop.html.erb b/app/views/summarys/_shop.html.erb index a7d974d..2875094 100644 --- a/app/views/summarys/_shop.html.erb +++ b/app/views/summarys/_shop.html.erb @@ -1,9 +1,7 @@ <%= polaris_index_table(rows) do |table| %> <% table.with_column(t('.shop', date: nil)) do |row| %> -
- <%= row[0] %> -
+ <%= row[0] %> <% end %> <% table.with_column(t('.payments')) do |row| %> diff --git a/app/views/users/_count_usage_charges_as_recurring_fields.html.erb b/app/views/users/_count_usage_charges_as_recurring_fields.html.erb index f8f485c..dddf5ea 100644 --- a/app/views/users/_count_usage_charges_as_recurring_fields.html.erb +++ b/app/views/users/_count_usage_charges_as_recurring_fields.html.erb @@ -1,4 +1,4 @@ -<%= form.fields_for current_user do |user_fields| %> +<%= form.fields_for :user do |user_fields| %> <%= user_fields.polaris_check_box :count_usage_charges_as_recurring, label: t('user.count_usage_charges_as_recurring'), help_text: t('user.count_usage_charges_as_recurring_help_text') diff --git a/config/locales/en.yml b/config/locales/en.yml index 75aa796..aa206ad 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4,6 +4,7 @@ en: view_details: "View details" save: "Save" discard: "Discard" + cancel: "Cancel" choose_file: "Choose file" delete: "Delete %{resource}" delete_all: "Delete all %{resource}" @@ -26,6 +27,8 @@ en: description: "Import your payouts data to get started." footer_html: If you don’t want to upload historical data, connect your %{url} data source. user: + show_forecasts: "Show forecasts" + show_forecasts_help_text: "Requires a minimum of 10 data points. Can be slow to calculate." count_usage_charges_as_recurring: "Count usage charges as recurring revenue" count_usage_charges_as_recurring_help_text: "Usage charges are counted as one-time revenue by default." devise: diff --git a/db/migrate/20230914091554_add_show_forecasts_to_users.rb b/db/migrate/20230914091554_add_show_forecasts_to_users.rb new file mode 100644 index 0000000..e0f14c0 --- /dev/null +++ b/db/migrate/20230914091554_add_show_forecasts_to_users.rb @@ -0,0 +1,5 @@ +class AddShowForecastsToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :show_forecasts, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index e37defa..6a253ad 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_09_11_110717) do +ActiveRecord::Schema[7.0].define(version: 2023_09_14_091554) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -126,6 +126,7 @@ t.integer "partner_api_organization_id" t.boolean "count_usage_charges_as_recurring", default: false t.text "partner_api_errors" + t.boolean "show_forecasts", default: false, null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end