This commit is contained in:
Ryan Kulp
2025-07-09 09:23:00 -04:00
parent a2b1460265
commit ed0e4daf68
6 changed files with 854 additions and 0 deletions
+1
View File
@@ -17,6 +17,7 @@ community Recipes:
- [Hacker News](/lib/hacker_news)
- [Lunar Calendar](/lib/lunar_calendar)
- [Lunch Money](/lib/lunch_money)
- [Shopify](/lib/shopify)
- [Stock Price](/lib/stock_price)
- [Tempest Weather Station](/lib/tempest_weather_station)
- [Todoist](/lib/todoist)
+180
View File
@@ -0,0 +1,180 @@
module Plugins
class Shopify < Base
def locals
{ metrics:, histogram:, currency:, comparison_period: comparison_period.map { it.to_date.to_s } }
end
private
def metrics
kpi = {}
kpi[:open_order_count] = fetch_order_count('open')
kpi[:closed_order_count] = fetch_order_count('closed')
total_orders = kpi[:open_order_count] + kpi[:closed_order_count]
if total_orders.positive?
fetch_orders
kpi[:order_sales] = calculate_sale_value || 0
kpi[:aov] = (kpi[:order_sales] / (kpi[:open_order_count] + kpi[:closed_order_count])).round(2)
end
kpi
rescue InvalidURL
{ errors: "Invalid Store Web Address or Access Token" }
end
def fetch_orders
@orders ||= query_sales_orders(lookback_period.to_s, nil)
@comparison_orders ||= query_sales_orders(*comparison_period)
end
def histogram
return { current: [], comparison: [] } if @orders.nil?
current = prepare_charting_data(@orders)
comparison = prepare_charting_data(@comparison_orders)
comparison = normalize_with_current(comparison, current)
{ current:, comparison: }
end
def prepare_charting_data(orders)
return [] if orders.nil? || orders.empty?
order_data = orders&.map { { date: Date.parse(it['created_at']).to_s, value: it['current_subtotal_price'].to_f } }
&.group_by { it[:date] }
&.map { |k, v| { date: k, value: v.sum { it[:value] }.round(2) } }
dates = order_data.map { it[:date] }
min_date = Date.parse(dates.min)
max_date = Date.parse(dates.max)
all_dates_hash = (min_date..max_date).each_with_object({}) do |date, hash|
hash[date.to_s] = { date: date.to_s, value: 0.0 }
end
order_data.each do |order|
all_dates_hash[order[:date]] = order
end
all_dates_hash.values.sort_by { it[:date] }
end
def normalize_with_current(comparison, current)
normalized_data = []
case settings['lookback_period']
when 'last_7_days'
current.each_with_index do |data, index|
next if comparison[index].nil?
normalized_data.push({ date: data[:date], value: comparison[index][:value] })
end
else
current.each do |data|
selected_date = comparison.select { (Date.parse(it[:date]) + 1.month).to_s == data[:date] }.first
next unless selected_date
normalized_data.push({ date: data[:date], value: selected_date[:value] })
end
end
normalized_data
end
def calculate_sale_value
@orders&.sum { |o| o['current_subtotal_price'].to_f }
end
def lookback_period
case settings['lookback_period']
when 'last_7_days'
(user.datetime_now - 7.days).iso8601
when 'last_30_days'
(user.datetime_now - 30.days).iso8601
when 'month_to_date'
user.datetime_now.beginning_of_month.iso8601
end
end
def comparison_period
case settings['lookback_period']
when 'last_7_days'
[(user.datetime_now - 14.days).iso8601, (user.datetime_now - 7.days).iso8601]
when 'last_30_days'
[(user.datetime_now - 60.days).iso8601, (user.datetime_now - 30.days).iso8601]
when 'month_to_date'
[(user.datetime_now.beginning_of_month - 1.month).iso8601, (user.datetime_now - 1.month).iso8601]
end
end
def fetch_order_count(status = 'any')
client.get(
path: 'orders/count',
query: {
status: status,
created_at_min: ">=#{lookback_period}"
}
).body['count']
rescue ShopifyAPI::Errors::HttpResponseError => e
error_message = e.response.body['errors']
raise InvalidURL if error_message.include?('Not Found') || error_message.include?('Invalid API key or access token')
end
def query_sales_orders(start_window, end_window)
orders = []
run_loop = true
while run_loop
retrieved_orders = client.get(
path: 'orders',
query: {
status: 'any',
limit: 250,
created_at_min: start_window,
processed_at_max: orders&.last&.dig('created_at') || end_window
}
).body['orders']
run_loop = false if retrieved_orders.count < 250
orders += retrieved_orders.reject { |m| m['cancelled_at'].present? }
end
orders
end
def currency
code = client.get(path: 'shop').body['shop']['currency']
{
'USD' => '$',
'EUR' => '€',
'CAD' => '$',
'CHF' => '₣',
'GBP' => '£',
'KRW' => '₩',
'CNY' => '¥',
'JPY' => '¥',
'INR' => '₹',
'ZAR' => 'R'
}[code] || '$'
rescue ShopifyAPI::Errors::HttpResponseError => e
error_message = e.response.body['errors']
raise InvalidURL if error_message.include?('Not Found') || error_message.include?('Invalid API key or access token')
end
def client = ShopifyAPI::Clients::Rest::Admin.new(session:)
def session = ShopifyAPI::Auth::Session.new(shop:, access_token:)
def myshopify_domain
shop = settings['shop']
shop.include?('myshopify') ? shop.split('.')[0] : shop
end
def shop = "#{myshopify_domain.strip}.myshopify.com"
def access_token = settings['access_token']
end
end
+184
View File
@@ -0,0 +1,184 @@
<% unless (metrics[:errors].present?) %>
<div class="view view--full">
<div class="layout layout--col gap--space-between">
<div class="grid">
<div class="row">
<div class="grid">
<div class="item col--span-2">
<div class="meta"></div>
<div class="content">
<span class="value value--large value--tnums" data-value-type="number"><%= number_to_currency(metrics[:order_sales], unit: currency, precision: 0) %></span>
<span class="label">Total Sales</span>
</div>
</div>
<div class="item col--span-1">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums" data-value-type="number"><%= number_with_delimiter(metrics[:open_order_count]) %></span>
<span class="label">Pending Orders</span>
</div>
</div>
<div class="item col--span-1">
<div class="meta"></div>
<div class="content">
<span class="value value--xsmall value--tnums" data-value-type="date">
<div class="w--14 h--1.5 mb--2 bg--black" style="border-radius: 20px;"></div>
<% if histogram[:current].present? %>
<%= DateTime.parse(histogram[:current].first[:date]).strftime("%b %d") %> -
<%= DateTime.parse(histogram[:current].last[:date]).strftime("%b %d") %>
<% end %>
</span>
<span class="label"><%= histogram.first[0].capitalize %></span>
</div>
</div>
</div>
</div>
</div>
<div class="border--h-5 w--full"></div>
<div class="grid">
<div class="row">
<div class="grid">
<div class="item col--span-2">
<div class="meta"></div>
<div class="content">
<span class="value value--tnums" data-value-type="number"><%= number_to_currency(metrics[:aov], unit: currency, precision: 0) %></span>
<span class="label">AOV</span>
</div>
</div>
<div class="item col--span-1">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums" data-value-type="number"><%= number_with_delimiter(metrics[:closed_order_count]) %></span>
<span class="label">Fulfilled Orders</span>
</div>
</div>
<div class="item col--span-1">
<div class="meta"></div>
<div class="content">
<span class="value value--xsmall value--tnums" data-value-type="date">
<div class="w--14 h--1.5 mb--2 bg--gray-5"></div>
<%= DateTime.parse(comparison_period.first).strftime("%b %d") %> -
<%= DateTime.parse(comparison_period.last).strftime("%b %d") %>
</span>
<span class="label"><%= histogram.to_a.last[0].capitalize %></span>
</div>
</div>
</div>
</div>
</div>
<%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %>
<% formatted_data = histogram.map { |key, period| { name: key.capitalize, data: period.map { [it[:date], it[:value]] }}} %>
<% chart_id = "chart-#{SecureRandom.hex(6)}" %>
<div id="<%= chart_id %>" class="w--full"></div>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
var data = <%= raw formatted_data.to_json %>;
Highcharts.chart('<%= chart_id %>', {
chart: {
type: 'spline',
height: 203,
width: null,
animation: false,
spacing: [10, 10, 5, 10]
},
title: {
text: null
},
plotOptions: {
series: {
animation: false,
enableMouseTracking: false,
states: {
hover: { enabled: false }
},
marker: {
enabled: false
}
}
},
series: [{
data: data[0].data,
lineWidth: 4,
color: '#000000',
name: data[0].name,
zIndex: 2
}, {
data: data[1].data,
lineWidth: 5,
name: data[1].name,
zIndex: 1,
color: {
pattern: {
image: 'https://usetrmnl.com/images/grayscale/gray-5.png',
width: 12,
height: 12
}
}
}],
tooltip: { enabled: false },
legend: { enabled: false },
yAxis: {
labels: {
style: { fontSize: '16px', color: '#000000' }
},
gridLineDashStyle: 'shortdot',
gridLineWidth: 1,
gridLineColor: '#000000',
tickAmount: 5,
title: {
text: null
}
},
xAxis: {
type: 'datetime',
labels: {
style: { fontSize: '16px', color: '#000000' },
padding: 5,
y: 25
},
lineWidth: 0,
gridLineDashStyle: 'dot',
tickWidth: 1,
tickLength: 0,
gridLineWidth: 1,
gridLineColor: '#000000',
tickPixelInterval: 120,
offset: 0,
margin: 0,
title: {
text: null
}
},
credits: {
enabled: false
}
});
});
</script>
</div>
<div class="title_bar">
<img class="image" src="<%= Rails.application.credentials.base_url %>/images/plugins/shopify--render.svg" />
<span class="title">Shopify</span>
<span class="instance"><%= instance_name %></span>
</div>
</div>
<% else %>
<%= render partial: 'plugins/errors/full', locals: {
icon: 'shopify--render.svg',
title: 'Shopify',
instance: instance_name,
error_message: metrics[:errors]
}
%>
<% end %>
+178
View File
@@ -0,0 +1,178 @@
<% unless (metrics[:errors].present?) %>
<div class="view view--half_horizontal">
<div class="layout layout--col gap--space-between">
<div class="grid h-full">
<div class="col--span-3 col gap--space-between">
<div class="grid grid--cols-2 gap--[9px]" data-adjust-grid-gaps="false">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--tnums" data-value-type="number"><%= number_to_currency(metrics[:order_sales], unit: currency, precision: 0) %></span>
<span class="label">Total Sales</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--tnums" data-value-type="number"><%= number_to_currency(metrics[:aov], unit: currency, precision: 0) %></span>
<span class="label">AOV</span>
</div>
</div>
</div>
<div class="w-full b-h-gray-5"></div>
<div class="grid grid--cols-2">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xsmall value--tnums" data-value-type="number"><%= number_with_delimiter(metrics[:open_order_count]) %></span>
<span class="label">Pending Orders</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xsmall value--tnums" data-value-type="number"><%= number_with_delimiter(metrics[:closed_order_count]) %></span>
<span class="label">Fulfilled Orders</span>
</div>
</div>
</div>
<div class="w-full b-h-gray-5"></div>
<div class="grid grid grid--cols-2">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xxsmall value--tnums" data-value-type="date">
<div class="w--12 h--1.5 mb--2 bg--gray-5"></div>
<%= DateTime.parse(comparison_period.first).strftime("%b %d") %> -
<%= DateTime.parse(comparison_period.last).strftime("%b %d") %>
</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xxsmall value--tnums" data-value-type="date">
<div class="w--12 h--1.5 mb--2 bg--black" style="border-radius: 20px;"></div>
<% if histogram[:current].present? %>
<%= DateTime.parse(histogram[:current].first[:date]).strftime("%b %d") %> -
<%= DateTime.parse(histogram[:current].last[:date]).strftime("%b %d") %>
<% end %>
</span>
</div>
</div>
</div>
</div>
<div class="col--span-3 col gap--space-between">
<% formatted_data = histogram.map { |key, period| { name: key.capitalize, data: period.map { [it[:date], it[:value]] }}} %>
<% chart_id = "chart-#{SecureRandom.hex(6)}" %>
<div id="<%= chart_id %>" class="w--full"></div>
<%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
var data = <%= raw formatted_data.to_json %>;
Highcharts.chart('<%= chart_id %>', {
chart: {
type: 'spline',
height: 180,
width: 376,
animation: false,
spacing: [10, 0, 10, 0]
},
title: {
text: null
},
plotOptions: {
series: {
animation: false,
enableMouseTracking: false,
states: {
hover: { enabled: false }
},
marker: {
enabled: false
}
}
},
series: [{
data: data[0].data,
lineWidth: 4,
color: '#000000',
name: data[0].name,
zIndex: 2
}, {
data: data[1].data,
lineWidth: 5,
name: data[1].name,
zIndex: 1,
color: {
pattern: {
image: 'https://usetrmnl.com/images/grayscale/gray-5.png',
width: 12,
height: 12
}
}
}],
tooltip: { enabled: false },
legend: { enabled: false },
yAxis: {
labels: {
style: { fontSize: '16px', color: '#000000' },
formatter: function() {
return '<%= currency %>' + (this.value / 1000) + 'k';
}
},
gridLineDashStyle: 'shortdot',
gridLineWidth: 1,
gridLineColor: '#000000',
tickAmount: 5,
title: {
text: null
}
},
xAxis: {
type: 'datetime',
labels: {
style: { fontSize: '16px', color: '#000000' },
padding: 5,
y: 25
},
lineWidth: 0,
gridLineDashStyle: 'dot',
tickWidth: 1,
tickLength: 0,
gridLineWidth: 1,
gridLineColor: '#000000',
tickPixelInterval: 120,
offset: 0,
margin: 0,
title: {
text: null
}
},
credits: {
enabled: false
}
});
});
</script>
</div>
</div>
</div>
<div class="title_bar">
<img class="image" src="<%= Rails.application.credentials.base_url %>/images/plugins/shopify--render.svg" />
<span class="title">Shopify</span>
<span class="instance"><%= instance_name %></span>
</div>
</div>
<% else %>
<%= render partial: 'plugins/errors/half_horizontal', locals: {
icon: 'shopify--render.svg',
title: 'Shopify',
instance: instance_name,
error_message: metrics[:errors]
} %>
<% end %>
+177
View File
@@ -0,0 +1,177 @@
<% unless (metrics[:errors].present?) %>
<div class="view view--half_vertical">
<div class="layout layout--col gap--space-between">
<div class="grid grid grid--cols-2">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--tnums" data-value-type="number"><%= number_to_currency(metrics[:order_sales], unit: currency, precision: 0) %></span>
<span class="label">Total Sales</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--tnums" data-value-type="number"><%= number_to_currency(metrics[:aov], unit: currency, precision: 0) %></span>
<span class="label">AOV</span>
</div>
</div>
</div>
<div class="border--h-5 w--full"></div>
<div class="grid grid grid--cols-2">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums" data-value-type="number"><%= number_with_delimiter(metrics[:open_order_count]) %></span>
<span class="label">Pending Orders</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums" data-value-type="number"><%= number_with_delimiter(metrics[:closed_order_count]) %></span>
<span class="label">Fulfilled Orders</span>
</div>
</div>
</div>
<div class="border--h-5 w--full"></div>
<div class="grid grid grid--cols-2">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xxsmall value--tnums" data-value-type="date">
<div class="w--12 h--1.5 mb--2 bg--gray-5"></div>
<%= DateTime.parse(comparison_period.first).strftime("%b %d") %> -
<%= DateTime.parse(comparison_period.last).strftime("%b %d") %>
</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xxsmall value--tnums" data-value-type="date">
<div class="w--12 h--1.5 mb--2 bg--black" style="border-radius: 20px;"></div>
<% if histogram[:current].present? %>
<%= DateTime.parse(histogram[:current].first[:date]).strftime("%b %d") %> -
<%= DateTime.parse(histogram[:current].last[:date]).strftime("%b %d") %>
<% end %>
</span>
</div>
</div>
</div>
<% formatted_data = histogram.map { |key, period| { name: key.capitalize, data: period.map { [it[:date], it[:value]] }}} %>
<% chart_id = "chart-#{SecureRandom.hex(6)}" %>
<div id="<%= chart_id %>" class="w--full"></div>
<%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
var data = <%= raw formatted_data.to_json %>;
Highcharts.chart('<%= chart_id %>', {
chart: {
type: 'spline',
height: 180,
width: null,
animation: false,
spacing: [10, 0, 0, 0]
},
title: {
text: null
},
plotOptions: {
series: {
animation: false,
enableMouseTracking: false,
states: {
hover: { enabled: false }
},
marker: {
enabled: false
}
}
},
series: [{
data: data[0].data,
lineWidth: 4,
color: '#000000',
name: data[0].name,
zIndex: 2
}, {
data: data[1].data,
lineWidth: 5,
name: data[1].name,
zIndex: 1,
color: {
pattern: {
image: 'https://usetrmnl.com/images/grayscale/gray-5.png',
width: 12,
height: 12
}
}
}],
tooltip: { enabled: false },
legend: { enabled: false },
yAxis: {
labels: {
style: { fontSize: '16px', color: '#000000' },
formatter: function() {
return '<%= currency %>' + (this.value / 1000) + 'k';
}
},
gridLineDashStyle: 'shortdot',
gridLineWidth: 1,
gridLineColor: '#000000',
tickAmount: 5,
title: {
text: null
}
},
xAxis: {
type: 'datetime',
labels: {
style: { fontSize: '16px', color: '#000000' },
padding: 5,
y: 25
},
lineWidth: 0,
gridLineDashStyle: 'dot',
tickWidth: 1,
tickLength: 0,
gridLineWidth: 1,
gridLineColor: '#000000',
tickPixelInterval: 120,
offset: 0,
margin: 0,
title: {
text: null
}
},
credits: {
enabled: false
}
});
});
</script>
</div>
<div class="title_bar">
<img class="image" src="<%= Rails.application.credentials.base_url %>/images/plugins/shopify--render.svg" />
<span class="title">Shopify</span>
<span class="instance"><%= instance_name %></span>
</div>
</div>
<% else %>
<%= render partial: 'plugins/errors/half_vertical', locals: {
icon: 'shopify--render.svg',
title: 'Shopify',
instance: instance_name,
error_message: metrics[:errors]
} %>
<% end %>
+134
View File
@@ -0,0 +1,134 @@
<% unless (metrics[:errors].present?) %>
<div class="view view--quadrant">
<div class="layout layout--col gap--space-between">
<div class="grid grid--cols-2 gap--[9px]" data-adjust-grid-gaps="false">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--tnums" data-value-type="number"><%= number_to_currency(metrics[:order_sales], unit: currency, precision: 0) %></span>
<span class="label">Total Sales</span>
</div>
</div>
<div class="item">
<% formatted_data = histogram.map { |key, period| { name: key.capitalize, data: period.map { [it[:date], it[:value]] }}} %>
<% chart_id = "chart-#{SecureRandom.hex(6)}" %>
<div id="<%= chart_id %>" class="w--full"></div>
<%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
var data = <%= raw formatted_data.to_json %>;
Highcharts.chart('<%= chart_id %>', {
chart: {
type: 'spline',
height: 60,
width: null,
animation: false,
spacing: [0, 0, 0, 0]
},
title: {
text: null
},
plotOptions: {
series: {
animation: false,
enableMouseTracking: false,
states: {
hover: { enabled: false }
},
marker: {
enabled: false
}
}
},
series: [{
data: data[0].data,
lineWidth: 4,
color: '#000000',
name: data[0].name,
zIndex: 2
}, {
data: data[1].data,
lineWidth: 5,
name: data[1].name,
zIndex: 1,
color: {
pattern: {
image: 'https://usetrmnl.com/images/grayscale/gray-5.png',
width: 12,
height: 12
}
}
}],
tooltip: { enabled: false },
legend: { enabled: false },
yAxis: { visible: false },
xAxis: { visible: false },
credits: {
enabled: false
}
});
});
</script>
</div>
</div>
<div class="w-full b-h-gray-5"></div>
<div class="grid grid--cols-2">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xsmall value--tnums" data-value-type="number"><%= number_with_delimiter(metrics[:open_order_count]) %></span>
<span class="label">Pending Orders</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xsmall value--tnums" data-value-type="number"><%= number_with_delimiter(metrics[:closed_order_count]) %></span>
<span class="label">Fulfilled Orders</span>
</div>
</div>
</div>
<div class="w-full b-h-gray-5"></div>
<div class="grid grid grid--cols-2">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xxsmall value--tnums" data-value-type="date">
<div class="w--12 h--1.5 mb--2 bg--gray-5"></div>
<%= DateTime.parse(comparison_period.first).strftime("%b %d") %> -
<%= DateTime.parse(comparison_period.last).strftime("%b %d") %>
</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--xxsmall value--tnums" data-value-type="date">
<div class="w--12 h--1.5 mb--2 bg--black" style="border-radius: 20px;"></div>
<%= DateTime.parse(histogram[:current].first[:date]).strftime("%b %d") %> -
<%= DateTime.parse(histogram[:current].last[:date]).strftime("%b %d") %>
</span>
</div>
</div>
</div>
</div>
<div class="title_bar">
<img class="image" src="<%= Rails.application.credentials.base_url %>/images/plugins/shopify--render.svg" />
<span class="title">Shopify</span>
<span class="instance"><%= instance_name %></span>
</div>
</div>
<% else %>
<%= render partial: 'plugins/errors/quadrant', locals: {
icon: 'shopify--render.svg',
title: 'Shopify',
instance: instance_name,
error_message: metrics[:errors]
} %>
<% end %>