mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 19:55:16 +00:00
Loading skellies :D
This commit is contained in:
parent
25d8035cff
commit
6c15a4a8b4
7 changed files with 266 additions and 157 deletions
23
app/controllers/api/v1/dashboard_stats_controller.rb
Normal file
23
app/controllers/api/v1/dashboard_stats_controller.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
module Api
|
||||
module V1
|
||||
class DashboardStatsController < ApplicationController
|
||||
include DashboardData
|
||||
|
||||
before_action :require_session_user!
|
||||
|
||||
def show
|
||||
render json: {
|
||||
filterable_dashboard_data: filterable_dashboard_data,
|
||||
activity_graph: activity_graph_data,
|
||||
today_stats: today_stats_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_session_user!
|
||||
render json: { error: "Unauthorized" }, status: :unauthorized unless current_user
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
140
app/controllers/concerns/dashboard_data.rb
Normal file
140
app/controllers/concerns/dashboard_data.rb
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
module DashboardData
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def filterable_dashboard_data
|
||||
filters = %i[project language operating_system editor category]
|
||||
interval = params[:interval]
|
||||
key = [ current_user ] + filters.map { |f| params[f] } + [ interval.to_s, params[:from], params[:to] ]
|
||||
hb = current_user.heartbeats
|
||||
h = ApplicationController.helpers
|
||||
|
||||
Rails.cache.fetch(key, expires_in: 5.minutes) do
|
||||
archived = current_user.project_repo_mappings.archived.pluck(:project_name)
|
||||
result = {}
|
||||
|
||||
Time.use_zone(current_user.timezone) do
|
||||
filters.each do |f|
|
||||
options = current_user.heartbeats.distinct.pluck(f).compact_blank
|
||||
options = options.reject { |n| archived.include?(n) } if f == :project
|
||||
result[f] = options.map { |k|
|
||||
f == :language ? k.categorize_language : (%i[operating_system editor].include?(f) ? k.capitalize : k)
|
||||
}.uniq
|
||||
|
||||
next unless params[f].present?
|
||||
arr = params[f].split(",")
|
||||
hb = if %i[operating_system editor].include?(f)
|
||||
hb.where(f => arr.flat_map { |v| [ v.downcase, v.capitalize ] }.uniq)
|
||||
elsif f == :language
|
||||
raw = current_user.heartbeats.distinct.pluck(f).compact_blank.select { |l| arr.include?(l.categorize_language) }
|
||||
raw.any? ? hb.where(f => raw) : hb
|
||||
else
|
||||
hb.where(f => arr)
|
||||
end
|
||||
result["singular_#{f}"] = arr.length == 1
|
||||
end
|
||||
|
||||
hb = hb.filter_by_time_range(interval, params[:from], params[:to])
|
||||
result[:total_time] = hb.duration_seconds
|
||||
result[:total_heartbeats] = hb.count
|
||||
|
||||
filters.each do |f|
|
||||
stats = hb.group(f).duration_seconds
|
||||
stats = stats.reject { |n, _| archived.include?(n) } if f == :project
|
||||
result["top_#{f}"] = stats.max_by { |_, v| v }&.first
|
||||
end
|
||||
|
||||
result["top_editor"] &&= h.display_editor_name(result["top_editor"])
|
||||
result["top_operating_system"] &&= h.display_os_name(result["top_operating_system"])
|
||||
result["top_language"] &&= h.display_language_name(result["top_language"])
|
||||
|
||||
unless result["singular_project"]
|
||||
result[:project_durations] = hb.group(:project).duration_seconds
|
||||
.reject { |p, _| archived.include?(p) }.sort_by { |_, d| -d }.first(10).to_h
|
||||
end
|
||||
|
||||
%i[language editor operating_system category].each do |f|
|
||||
next if result["singular_#{f}"]
|
||||
stats = hb.group(f).duration_seconds.each_with_object({}) do |(raw, dur), agg|
|
||||
k = raw.to_s.presence || "Unknown"
|
||||
k = f == :language ? (k == "Unknown" ? k : k.categorize_language) : (%i[editor operating_system].include?(f) ? k.downcase : k)
|
||||
agg[k] = (agg[k] || 0) + dur
|
||||
end
|
||||
result["#{f}_stats"] = stats.sort_by { |_, d| -d }.first(10).map { |k, v|
|
||||
label = case f
|
||||
when :editor then h.display_editor_name(k)
|
||||
when :operating_system then h.display_os_name(k)
|
||||
when :language then h.display_language_name(k)
|
||||
else k
|
||||
end
|
||||
[ label, v ]
|
||||
}.to_h
|
||||
end
|
||||
|
||||
result[:weekly_project_stats] = (0..11).to_h do |w|
|
||||
ws = w.weeks.ago.beginning_of_week
|
||||
[ ws.to_date.iso8601, hb.where(time: ws.to_f..w.weeks.ago.end_of_week.to_f)
|
||||
.group(:project).duration_seconds.reject { |p, _| archived.include?(p) } ]
|
||||
end
|
||||
end
|
||||
result[:selected_interval] = interval.to_s
|
||||
result[:selected_from] = params[:from].to_s
|
||||
result[:selected_to] = params[:to].to_s
|
||||
filters.each { |f| result["selected_#{f}"] = params[f]&.split(",") || [] }
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def activity_graph_data
|
||||
tz = current_user.timezone
|
||||
key = "user_#{current_user.id}_daily_durations_#{tz}"
|
||||
durations = Rails.cache.fetch(key, expires_in: 1.minute) do
|
||||
Time.use_zone(tz) { current_user.heartbeats.daily_durations(user_timezone: tz).to_h }
|
||||
end
|
||||
|
||||
{
|
||||
start_date: 365.days.ago.to_date.iso8601,
|
||||
end_date: Time.current.to_date.iso8601,
|
||||
duration_by_date: durations.transform_keys { |d| d.to_date.iso8601 }.transform_values(&:to_i),
|
||||
busiest_day_seconds: 8.hours.to_i,
|
||||
timezone_label: ActiveSupport::TimeZone[tz].to_s,
|
||||
timezone_settings_path: "/my/settings#user_timezone"
|
||||
}
|
||||
end
|
||||
|
||||
def today_stats_data
|
||||
h = ApplicationController.helpers
|
||||
Time.use_zone(current_user.timezone) do
|
||||
rows = current_user.heartbeats.today
|
||||
.select(:language, :editor,
|
||||
"COUNT(*) OVER (PARTITION BY language) as language_count",
|
||||
"COUNT(*) OVER (PARTITION BY editor) as editor_count")
|
||||
.distinct.to_a
|
||||
|
||||
lang_counts = rows
|
||||
.map { |r| [ r.language&.categorize_language, r.language_count ] }
|
||||
.reject { |l, _| l.blank? }
|
||||
.group_by(&:first).transform_values { |p| p.sum(&:last) }
|
||||
.sort_by { |_, c| -c }
|
||||
|
||||
ed_counts = rows
|
||||
.map { |r| [ r.editor, r.editor_count ] }
|
||||
.reject { |e, _| e.blank? }.uniq
|
||||
.sort_by { |_, c| -c }
|
||||
|
||||
todays_languages = lang_counts.map { |l, _| h.display_language_name(l) }
|
||||
todays_editors = ed_counts.map { |e, _| h.display_editor_name(e) }
|
||||
todays_duration = current_user.heartbeats.today.duration_seconds
|
||||
show_logged_time_sentence = todays_duration > 1.minute && (todays_languages.any? || todays_editors.any?)
|
||||
|
||||
{
|
||||
show_logged_time_sentence: show_logged_time_sentence,
|
||||
todays_duration_display: h.short_time_detailed(todays_duration.to_i),
|
||||
todays_languages: todays_languages,
|
||||
todays_editors: todays_editors
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
class StaticPagesController < InertiaController
|
||||
include DashboardData
|
||||
|
||||
layout "inertia", only: :index
|
||||
before_action :ensure_current_user, only: %i[
|
||||
filterable_dashboard
|
||||
|
|
@ -23,31 +25,6 @@ class StaticPagesController < InertiaController
|
|||
end
|
||||
end
|
||||
|
||||
Time.use_zone(current_user.timezone) do
|
||||
h = ApplicationController.helpers
|
||||
rows = current_user.heartbeats.today
|
||||
.select(:language, :editor,
|
||||
"COUNT(*) OVER (PARTITION BY language) as language_count",
|
||||
"COUNT(*) OVER (PARTITION BY editor) as editor_count")
|
||||
.distinct.to_a
|
||||
|
||||
lang_counts = rows
|
||||
.map { |r| [ r.language&.categorize_language, r.language_count ] }
|
||||
.reject { |l, _| l.blank? }
|
||||
.group_by(&:first).transform_values { |p| p.sum(&:last) }
|
||||
.sort_by { |_, c| -c }
|
||||
|
||||
ed_counts = rows
|
||||
.map { |r| [ r.editor, r.editor_count ] }
|
||||
.reject { |e, _| e.blank? }.uniq
|
||||
.sort_by { |_, c| -c }
|
||||
|
||||
@todays_languages = lang_counts.map { |l, _| h.display_language_name(l) }
|
||||
@todays_editors = ed_counts.map { |e, _| h.display_editor_name(e) }
|
||||
@todays_duration = current_user.heartbeats.today.duration_seconds
|
||||
@show_logged_time_sentence = @todays_duration > 1.minute && (@todays_languages.any? || @todays_editors.any?)
|
||||
end
|
||||
|
||||
render inertia: "Home/SignedIn", props: signed_in_props
|
||||
else
|
||||
# Set homepage SEO content for logged-out users only
|
||||
|
|
@ -170,7 +147,6 @@ class StaticPagesController < InertiaController
|
|||
end
|
||||
|
||||
def signed_in_props
|
||||
helpers = ApplicationController.helpers
|
||||
{
|
||||
flavor_text: @flavor_text.to_s,
|
||||
trust_level_red: current_user&.trust_level == "red",
|
||||
|
|
@ -181,12 +157,16 @@ class StaticPagesController < InertiaController
|
|||
github_uid_blank: current_user&.github_uid.blank?,
|
||||
github_auth_path: github_auth_path,
|
||||
wakatime_setup_path: my_wakatime_setup_path,
|
||||
show_logged_time_sentence: !!@show_logged_time_sentence,
|
||||
todays_duration_display: helpers.short_time_detailed(@todays_duration.to_i),
|
||||
todays_languages: @todays_languages || [],
|
||||
todays_editors: @todays_editors || [],
|
||||
filterable_dashboard_data: filterable_dashboard_data,
|
||||
activity_graph: activity_graph_data
|
||||
dashboard_stats_url: api_v1_dashboard_stats_path(
|
||||
interval: params[:interval],
|
||||
from: params[:from],
|
||||
to: params[:to],
|
||||
project: params[:project],
|
||||
language: params[:language],
|
||||
editor: params[:editor],
|
||||
operating_system: params[:operating_system],
|
||||
category: params[:category]
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -203,105 +183,4 @@ class StaticPagesController < InertiaController
|
|||
home_stats: @home_stats || {}
|
||||
}
|
||||
end
|
||||
|
||||
def filterable_dashboard_data
|
||||
filters = %i[project language operating_system editor category]
|
||||
interval = params[:interval]
|
||||
key = [ current_user ] + filters.map { |f| params[f] } + [ interval.to_s, params[:from], params[:to] ]
|
||||
hb = current_user.heartbeats
|
||||
h = ApplicationController.helpers
|
||||
|
||||
Rails.cache.fetch(key, expires_in: 5.minutes) do
|
||||
archived = current_user.project_repo_mappings.archived.pluck(:project_name)
|
||||
result = {}
|
||||
|
||||
Time.use_zone(current_user.timezone) do
|
||||
filters.each do |f|
|
||||
options = current_user.heartbeats.distinct.pluck(f).compact_blank
|
||||
options = options.reject { |n| archived.include?(n) } if f == :project
|
||||
result[f] = options.map { |k|
|
||||
f == :language ? k.categorize_language : (%i[operating_system editor].include?(f) ? k.capitalize : k)
|
||||
}.uniq
|
||||
|
||||
next unless params[f].present?
|
||||
arr = params[f].split(",")
|
||||
hb = if %i[operating_system editor].include?(f)
|
||||
hb.where(f => arr.flat_map { |v| [ v.downcase, v.capitalize ] }.uniq)
|
||||
elsif f == :language
|
||||
raw = current_user.heartbeats.distinct.pluck(f).compact_blank.select { |l| arr.include?(l.categorize_language) }
|
||||
raw.any? ? hb.where(f => raw) : hb
|
||||
else
|
||||
hb.where(f => arr)
|
||||
end
|
||||
result["singular_#{f}"] = arr.length == 1
|
||||
end
|
||||
|
||||
hb = hb.filter_by_time_range(interval, params[:from], params[:to])
|
||||
result[:total_time] = hb.duration_seconds
|
||||
result[:total_heartbeats] = hb.count
|
||||
|
||||
filters.each do |f|
|
||||
stats = hb.group(f).duration_seconds
|
||||
stats = stats.reject { |n, _| archived.include?(n) } if f == :project
|
||||
result["top_#{f}"] = stats.max_by { |_, v| v }&.first
|
||||
end
|
||||
|
||||
result["top_editor"] &&= h.display_editor_name(result["top_editor"])
|
||||
result["top_operating_system"] &&= h.display_os_name(result["top_operating_system"])
|
||||
result["top_language"] &&= h.display_language_name(result["top_language"])
|
||||
|
||||
unless result["singular_project"]
|
||||
result[:project_durations] = hb.group(:project).duration_seconds
|
||||
.reject { |p, _| archived.include?(p) }.sort_by { |_, d| -d }.first(10).to_h
|
||||
end
|
||||
|
||||
%i[language editor operating_system category].each do |f|
|
||||
next if result["singular_#{f}"]
|
||||
stats = hb.group(f).duration_seconds.each_with_object({}) do |(raw, dur), agg|
|
||||
k = raw.to_s.presence || "Unknown"
|
||||
k = f == :language ? (k == "Unknown" ? k : k.categorize_language) : (%i[editor operating_system].include?(f) ? k.downcase : k)
|
||||
agg[k] = (agg[k] || 0) + dur
|
||||
end
|
||||
result["#{f}_stats"] = stats.sort_by { |_, d| -d }.first(10).map { |k, v|
|
||||
label = case f
|
||||
when :editor then h.display_editor_name(k)
|
||||
when :operating_system then h.display_os_name(k)
|
||||
when :language then h.display_language_name(k)
|
||||
else k
|
||||
end
|
||||
[ label, v ]
|
||||
}.to_h
|
||||
end
|
||||
|
||||
result[:weekly_project_stats] = (0..11).to_h do |w|
|
||||
ws = w.weeks.ago.beginning_of_week
|
||||
[ ws.to_date.iso8601, hb.where(time: ws.to_f..w.weeks.ago.end_of_week.to_f)
|
||||
.group(:project).duration_seconds.reject { |p, _| archived.include?(p) } ]
|
||||
end
|
||||
end
|
||||
result[:selected_interval] = interval.to_s
|
||||
result[:selected_from] = params[:from].to_s
|
||||
result[:selected_to] = params[:to].to_s
|
||||
filters.each { |f| result["selected_#{f}"] = params[f]&.split(",") || [] }
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def activity_graph_data
|
||||
tz = current_user.timezone
|
||||
key = "user_#{current_user.id}_daily_durations_#{tz}"
|
||||
durations = Rails.cache.fetch(key, expires_in: 1.minute) do
|
||||
Time.use_zone(tz) { current_user.heartbeats.daily_durations(user_timezone: tz).to_h }
|
||||
end
|
||||
|
||||
{
|
||||
start_date: 365.days.ago.to_date.iso8601,
|
||||
end_date: Time.current.to_date.iso8601,
|
||||
duration_by_date: durations.transform_keys { |d| d.to_date.iso8601 }.transform_values(&:to_i),
|
||||
busiest_day_seconds: 8.hours.to_i,
|
||||
timezone_label: ActiveSupport::TimeZone[tz].to_s,
|
||||
timezone_settings_path: "/my/settings#user_timezone"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { ActivityGraphData } from "../../types/index";
|
||||
import BanNotice from "./signedIn/BanNotice.svelte";
|
||||
import GitHubLinkBanner from "./signedIn/GitHubLinkBanner.svelte";
|
||||
import SetupNotice from "./signedIn/SetupNotice.svelte";
|
||||
import TodaySentence from "./signedIn/TodaySentence.svelte";
|
||||
import TodaySentenceSkeleton from "./signedIn/TodaySentenceSkeleton.svelte";
|
||||
import Dashboard from "./signedIn/Dashboard.svelte";
|
||||
import DashboardSkeleton from "./signedIn/DashboardSkeleton.svelte";
|
||||
import ActivityGraph from "./signedIn/ActivityGraph.svelte";
|
||||
import ActivityGraphSkeleton from "./signedIn/ActivityGraphSkeleton.svelte";
|
||||
|
||||
type SocialProofUser = { display_name: string; avatar_url: string };
|
||||
|
||||
|
|
@ -37,6 +41,13 @@
|
|||
selected_category: string[];
|
||||
};
|
||||
|
||||
type TodayStats = {
|
||||
show_logged_time_sentence: boolean;
|
||||
todays_duration_display: string;
|
||||
todays_languages: string[];
|
||||
todays_editors: string[];
|
||||
};
|
||||
|
||||
let {
|
||||
flavor_text,
|
||||
trust_level_red,
|
||||
|
|
@ -47,12 +58,7 @@
|
|||
github_uid_blank,
|
||||
github_auth_path,
|
||||
wakatime_setup_path,
|
||||
show_logged_time_sentence,
|
||||
todays_duration_display,
|
||||
todays_languages,
|
||||
todays_editors,
|
||||
filterable_dashboard_data,
|
||||
activity_graph,
|
||||
dashboard_stats_url,
|
||||
}: {
|
||||
flavor_text: string;
|
||||
trust_level_red: boolean;
|
||||
|
|
@ -63,13 +69,28 @@
|
|||
github_uid_blank: boolean;
|
||||
github_auth_path: string;
|
||||
wakatime_setup_path: string;
|
||||
show_logged_time_sentence: boolean;
|
||||
todays_duration_display: string;
|
||||
todays_languages: string[];
|
||||
todays_editors: string[];
|
||||
filterable_dashboard_data: FilterableDashboardData;
|
||||
activity_graph: ActivityGraphData;
|
||||
dashboard_stats_url: string;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let todayStats = $state<TodayStats | null>(null);
|
||||
let dashboardData = $state<FilterableDashboardData | null>(null);
|
||||
let activityGraph = $state<ActivityGraphData | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(dashboard_stats_url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
todayStats = data.today_stats;
|
||||
dashboardData = data.filterable_dashboard_data;
|
||||
activityGraph = data.activity_graph;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
|
@ -104,20 +125,32 @@
|
|||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- Today Stats & Leaderboard -->
|
||||
<!-- Today Stats -->
|
||||
<div>
|
||||
<TodaySentence
|
||||
{show_logged_time_sentence}
|
||||
{todays_duration_display}
|
||||
{todays_languages}
|
||||
{todays_editors}
|
||||
/>
|
||||
{#if loading}
|
||||
<TodaySentenceSkeleton />
|
||||
{:else if todayStats}
|
||||
<TodaySentence
|
||||
show_logged_time_sentence={todayStats.show_logged_time_sentence}
|
||||
todays_duration_display={todayStats.todays_duration_display}
|
||||
todays_languages={todayStats.todays_languages}
|
||||
todays_editors={todayStats.todays_editors}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Main Dashboard -->
|
||||
<Dashboard data={filterable_dashboard_data} />
|
||||
{#if loading}
|
||||
<DashboardSkeleton />
|
||||
{:else if dashboardData}
|
||||
<Dashboard data={dashboardData} />
|
||||
{/if}
|
||||
|
||||
<!-- Activity Graph -->
|
||||
<ActivityGraph data={activity_graph} />
|
||||
{#if loading}
|
||||
<ActivityGraphSkeleton />
|
||||
{:else if activityGraph}
|
||||
<ActivityGraph data={activityGraph} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,41 @@
|
|||
<script lang="ts">
|
||||
const filterRows = Array.from({ length: 6 });
|
||||
const statCards = Array.from({ length: 6 });
|
||||
</script>
|
||||
|
||||
<div class="max-w-6xl mx-auto my-0 animate-pulse">
|
||||
<div class="flex gap-4 mt-2 mb-6 flex-wrap">
|
||||
<div class="flex flex-col gap-6 w-full animate-pulse">
|
||||
<!-- Filter skeletons -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-2">
|
||||
{#each filterRows as _}
|
||||
<div class="filter flex-1 min-w-37.5 relative">
|
||||
<div class="flex-1 min-w-37.5 min-h-10 relative">
|
||||
<div class="h-3 w-16 bg-darkless rounded mb-1.5"></div>
|
||||
<div class="h-10 w-full bg-darkless rounded-lg"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Stat card skeletons -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{#each statCards as _, i}
|
||||
<div
|
||||
class={`flex flex-col justify-between p-4 pb-6 rounded-xl border h-full min-h-23.5 ${
|
||||
i === 0
|
||||
? "bg-primary/10 border-primary/30"
|
||||
: "bg-white/5 border-white/10"
|
||||
}`}
|
||||
>
|
||||
<div class="h-3 w-16 bg-darkless rounded mb-2"></div>
|
||||
<div class="h-5 w-24 bg-darkless rounded"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Chart area skeletons -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 w-full">
|
||||
<div class="h-48 bg-darkless/50 rounded-xl"></div>
|
||||
<div class="h-48 bg-darkless/50 rounded-xl"></div>
|
||||
<div class="h-48 bg-darkless/50 rounded-xl"></div>
|
||||
<div class="h-48 bg-darkless/50 rounded-xl"></div>
|
||||
<div class="lg:col-span-2 h-48 bg-darkless/50 rounded-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<div class="animate-pulse">
|
||||
<p>
|
||||
<span class="inline-block h-4 w-64 bg-darkless rounded"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -194,6 +194,8 @@ Rails.application.routes.draw do
|
|||
post :claim, on: :collection
|
||||
end
|
||||
|
||||
get "dashboard_stats", to: "dashboard_stats#show"
|
||||
|
||||
namespace :my do
|
||||
get "heartbeats/most_recent", to: "heartbeats#most_recent"
|
||||
get "heartbeats", to: "heartbeats#index"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue