mirror of
https://github.com/jcwimer/wrestlingApp
synced 2026-03-25 01:14:43 +00:00
Use turbo streams for the bout board instead of auto refreshing every 30 seconds.
This commit is contained in:
@@ -19,12 +19,14 @@ import WrestlerColorController from "controllers/wrestler_color_controller";
|
||||
import MatchScoreController from "controllers/match_score_controller";
|
||||
import MatchDataController from "controllers/match_data_controller";
|
||||
import MatchSpectateController from "controllers/match_spectate_controller";
|
||||
import UpMatchesConnectionController from "controllers/up_matches_connection_controller";
|
||||
|
||||
// Register controllers
|
||||
application.register("wrestler-color", WrestlerColorController);
|
||||
application.register("match-score", MatchScoreController);
|
||||
application.register("match-data", MatchDataController);
|
||||
application.register("match-spectate", MatchSpectateController);
|
||||
application.register("up-matches-connection", UpMatchesConnectionController);
|
||||
|
||||
// Your existing Action Cable consumer setup
|
||||
(function() {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["stream", "statusIndicator"]
|
||||
|
||||
connect() {
|
||||
this.setupSubscription()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.cleanupSubscription()
|
||||
}
|
||||
|
||||
setupSubscription() {
|
||||
this.cleanupSubscription()
|
||||
this.setStatus("Connecting to server for real-time bout board updates...", "info")
|
||||
|
||||
if (!this.hasStreamTarget) {
|
||||
this.setStatus("Error: Stream source not found.", "danger")
|
||||
return
|
||||
}
|
||||
|
||||
const signedStreamName = this.streamTarget.getAttribute("signed-stream-name")
|
||||
if (!signedStreamName) {
|
||||
this.setStatus("Error: Invalid stream source.", "danger")
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.App || !window.App.cable) {
|
||||
this.setStatus("Error: WebSockets unavailable. Bout board won't update in real-time. Refresh the page to update.", "danger")
|
||||
return
|
||||
}
|
||||
|
||||
this.subscription = App.cable.subscriptions.create(
|
||||
{
|
||||
channel: "Turbo::StreamsChannel",
|
||||
signed_stream_name: signedStreamName
|
||||
},
|
||||
{
|
||||
connected: () => {
|
||||
this.setStatus("Connected: Bout board updating in real-time.", "success")
|
||||
},
|
||||
disconnected: () => {
|
||||
this.setStatus("Disconnected: Live bout board updates paused.", "warning")
|
||||
},
|
||||
rejected: () => {
|
||||
this.setStatus("Error: Live bout board connection rejected.", "danger")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
cleanupSubscription() {
|
||||
if (!this.subscription) return
|
||||
this.subscription.unsubscribe()
|
||||
this.subscription = null
|
||||
}
|
||||
|
||||
setStatus(message, type) {
|
||||
if (!this.hasStatusIndicatorTarget) return
|
||||
|
||||
this.statusIndicatorTarget.innerText = message
|
||||
this.statusIndicatorTarget.classList.remove("alert-secondary", "alert-info", "alert-success", "alert-warning", "alert-danger")
|
||||
|
||||
if (type === "success") this.statusIndicatorTarget.classList.add("alert-success")
|
||||
else if (type === "warning") this.statusIndicatorTarget.classList.add("alert-warning")
|
||||
else if (type === "danger") this.statusIndicatorTarget.classList.add("alert-danger")
|
||||
else this.statusIndicatorTarget.classList.add("alert-info")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ class TournamentsController < ApplicationController
|
||||
before_action :check_access_manage, only: [:delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:swap,:weigh_in_sheet,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:school_delegate,:weigh_in,:weigh_in_weight,:create_custom_weights,:update,:edit,:generate_matches,:matches,:qrcode]
|
||||
before_action :check_access_destroy, only: [:destroy,:delegate,:remove_delegate]
|
||||
before_action :check_tournament_errors, only: [:generate_matches]
|
||||
before_action :check_for_matches, only: [:all_results,:up_matches,:bracket,:all_brackets]
|
||||
before_action :check_for_matches, only: [:all_results,:bracket,:all_brackets]
|
||||
before_action :check_access_read, only: [:all_results,:up_matches,:bracket,:all_brackets]
|
||||
|
||||
def weigh_in_sheet
|
||||
@@ -263,16 +263,8 @@ class TournamentsController < ApplicationController
|
||||
|
||||
|
||||
def up_matches
|
||||
# .where.not(loser1_name: 'BYE') won't return matches with NULL loser1_name
|
||||
# so I was only getting back matches with Loser of BOUT_NUMBER
|
||||
@matches = @tournament.matches
|
||||
.where("mat_id is NULL and (finished != 1 or finished is NULL)")
|
||||
.where("loser1_name != ? OR loser1_name IS NULL", "BYE")
|
||||
.where("loser2_name != ? OR loser2_name IS NULL", "BYE")
|
||||
.order('bout_number ASC')
|
||||
.limit(10)
|
||||
.includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
|
||||
@mats = @tournament.mats.includes(:matches)
|
||||
@matches = @tournament.up_matches_unassigned_matches
|
||||
@mats = @tournament.up_matches_mats
|
||||
end
|
||||
|
||||
def bout_sheets
|
||||
|
||||
@@ -9,6 +9,7 @@ class Mat < ApplicationRecord
|
||||
QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze
|
||||
|
||||
after_save :clear_queue_matches_cache
|
||||
after_commit :broadcast_up_matches_board, on: :update, if: :up_matches_queue_changed?
|
||||
|
||||
def assign_next_match
|
||||
slot = first_empty_queue_slot
|
||||
@@ -276,4 +277,12 @@ class Mat < ApplicationRecord
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_up_matches_board
|
||||
Tournament.broadcast_up_matches_board(tournament_id)
|
||||
end
|
||||
|
||||
def up_matches_queue_changed?
|
||||
saved_change_to_queue1? || saved_change_to_queue2? || saved_change_to_queue3? || saved_change_to_queue4?
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -17,6 +17,7 @@ class Match < ApplicationRecord
|
||||
# update mat show with correct match if bout board is reset
|
||||
# this is done with a turbo stream
|
||||
after_commit :broadcast_mat_assignment_change, if: :saved_change_to_mat_id?, on: [:create, :update]
|
||||
after_commit :broadcast_up_matches_board, on: :update, if: :saved_change_to_mat_id?
|
||||
|
||||
# Enqueue advancement and related actions after the DB transaction has committed.
|
||||
# Using after_commit ensures any background jobs enqueued inside these callbacks
|
||||
@@ -371,4 +372,8 @@ class Match < ApplicationRecord
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def broadcast_up_matches_board
|
||||
Tournament.broadcast_up_matches_board(tournament_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,6 +69,32 @@ class Tournament < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def up_matches_unassigned_matches
|
||||
matches
|
||||
.where("mat_id is NULL and (finished != 1 or finished is NULL)")
|
||||
.where("loser1_name != ? OR loser1_name IS NULL", "BYE")
|
||||
.where("loser2_name != ? OR loser2_name IS NULL", "BYE")
|
||||
.order("bout_number ASC")
|
||||
.limit(10)
|
||||
.includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
|
||||
end
|
||||
|
||||
def up_matches_mats
|
||||
mats.includes(:matches)
|
||||
end
|
||||
|
||||
def self.broadcast_up_matches_board(tournament_id)
|
||||
tournament = find_by(id: tournament_id)
|
||||
return unless tournament
|
||||
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
tournament,
|
||||
target: "up_matches_board",
|
||||
partial: "tournaments/up_matches_board",
|
||||
locals: { tournament: tournament }
|
||||
)
|
||||
end
|
||||
|
||||
def destroy_all_matches
|
||||
matches.destroy_all
|
||||
end
|
||||
|
||||
@@ -32,6 +32,7 @@ class GenerateTournamentMatches
|
||||
@tournament.reset_and_fill_bout_board
|
||||
@tournament.curently_generating_matches = nil
|
||||
@tournament.save!
|
||||
Tournament.broadcast_up_matches_board(@tournament.id)
|
||||
end
|
||||
|
||||
def assignBouts
|
||||
|
||||
45
app/views/tournaments/_up_matches_board.html.erb
Normal file
45
app/views/tournaments/_up_matches_board.html.erb
Normal file
@@ -0,0 +1,45 @@
|
||||
<div id="up_matches_board">
|
||||
<h3>Upcoming Matches</h3>
|
||||
<table class="table table-striped table-bordered table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mat </th>
|
||||
<th>On Mat</th>
|
||||
<th>On Deck</th>
|
||||
<th>In The Hole</th>
|
||||
<th>Warm Up</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% (local_assigns[:mats] || tournament.up_matches_mats).each do |m| %>
|
||||
<%= render "tournaments/up_matches_mat_row", mat: m %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
<h3>Matches not assigned</h3>
|
||||
<br>
|
||||
<table class="table table-striped table-bordered table-condensed" id="matchList">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Round</th>
|
||||
<th>Bout Number</th>
|
||||
<th>Weight Class</th>
|
||||
<th>Matchup</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% (local_assigns[:matches] || tournament.up_matches_unassigned_matches).each do |m| %>
|
||||
<tr>
|
||||
<td>Round <%= m.round %></td>
|
||||
<td><%= m.bout_number %></td>
|
||||
<td><%= m.weight_max %></td>
|
||||
<td><%= m.w1_bracket_name %> vs. <%= m.w2_bracket_name %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
</div>
|
||||
@@ -1,70 +1,19 @@
|
||||
<script>
|
||||
const setUpMatchesRefresh = () => {
|
||||
if (window.__upMatchesRefreshTimeout) {
|
||||
clearTimeout(window.__upMatchesRefreshTimeout);
|
||||
}
|
||||
window.__upMatchesRefreshTimeout = setTimeout(() => {
|
||||
window.location.reload(true);
|
||||
}, 30000);
|
||||
};
|
||||
<div data-controller="up-matches-connection">
|
||||
<% if params[:print] != "true" %>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<%= link_to "Show Bout Board in Full Screen", up_matches_path(@tournament, print: true), class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
document.addEventListener("turbo:load", setUpMatchesRefresh);
|
||||
// turbo:before-cache stops the timer refresh from occurring if you navigate away from up_matches
|
||||
document.addEventListener("turbo:before-cache", () => {
|
||||
if (window.__upMatchesRefreshTimeout) {
|
||||
clearTimeout(window.__upMatchesRefreshTimeout);
|
||||
window.__upMatchesRefreshTimeout = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<br>
|
||||
<br>
|
||||
<h5 style="color:red">This page reloads every 30s</h5>
|
||||
<br>
|
||||
<h3>Upcoming Matches</h3>
|
||||
<br>
|
||||
<table class="table table-striped table-bordered table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mat </th>
|
||||
<th>On Mat</th>
|
||||
<th>On Deck</th>
|
||||
<th>In The Hole</th>
|
||||
<th>Warm Up</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<%= turbo_stream_from @tournament, data: { up_matches_connection_target: "stream" } %>
|
||||
<div
|
||||
id="up-matches-cable-status-indicator"
|
||||
data-up-matches-connection-target="statusIndicator"
|
||||
class="alert alert-secondary"
|
||||
style="padding: 5px; margin-bottom: 10px; border-radius: 4px;"
|
||||
>
|
||||
Connecting to server for real-time up matches updates...
|
||||
</div>
|
||||
|
||||
<tbody>
|
||||
<% @mats.each.map do |m| %>
|
||||
<%= render "up_matches_mat_row", mat: m %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
<h3>Matches not assigned</h3>
|
||||
<br>
|
||||
<table class="table table-striped table-bordered table-condensed" id="matchList">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Round</th>
|
||||
<th>Bout Number</th>
|
||||
<th>Weight Class</th>
|
||||
<th>Matchup</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% if @matches.size > 0 %>
|
||||
<% @matches.each.map do |m| %>
|
||||
<tr>
|
||||
<td>Round <%= m.round %></td>
|
||||
<td><%= m.bout_number %></td>
|
||||
<td><%= m.weight_max %></td>
|
||||
<td><%= m.w1_bracket_name %> vs. <%= m.w2_bracket_name %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
<%= render "up_matches_board", tournament: @tournament, mats: @mats, matches: @matches %>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Async adapter only works within the same process, so for manually triggering cable updates from a console,
|
||||
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
|
||||
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
|
||||
# to make the web console appear.
|
||||
development:
|
||||
adapter: async
|
||||
adapter: solid_cable
|
||||
connects_to:
|
||||
database:
|
||||
writing: cable
|
||||
polling_interval: 0.1.seconds
|
||||
message_retention: 1.day
|
||||
|
||||
test:
|
||||
adapter: test
|
||||
|
||||
@@ -559,6 +559,36 @@ class TournamentsControllerTest < ActionController::TestCase
|
||||
get_up_matches
|
||||
success
|
||||
end
|
||||
|
||||
test "up matches uses turbo stream updates instead of timer refresh script" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
get_up_matches
|
||||
success
|
||||
assert_includes response.body, "turbo-cable-stream-source"
|
||||
assert_includes response.body, "data-controller=\"up-matches-connection\""
|
||||
assert_includes response.body, "up-matches-cable-status-indicator"
|
||||
assert_not_includes response.body, "This page reloads every 30s"
|
||||
end
|
||||
|
||||
test "up matches shows full screen button when print param is not true" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
get :up_matches, params: { id: @tournament.id }
|
||||
assert_response :success
|
||||
|
||||
assert_includes response.body, "Show Bout Board in Full Screen"
|
||||
assert_includes response.body, "print=true"
|
||||
end
|
||||
|
||||
test "up matches hides full screen button when print param is true" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
get :up_matches, params: { id: @tournament.id, print: "true" }
|
||||
assert_response :success
|
||||
|
||||
assert_not_includes response.body, "Show Bout Board in Full Screen"
|
||||
end
|
||||
# END UP MATCHES PAGE PERMISSIONS
|
||||
|
||||
# ALL_RESULTS PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC
|
||||
@@ -643,11 +673,11 @@ class TournamentsControllerTest < ActionController::TestCase
|
||||
# END ALL_RESULTS PAGE PERMISSIONS
|
||||
|
||||
#TESTS THAT NEED MATCHES PUT ABOVE THIS
|
||||
test "redirect up_matches if no matches" do
|
||||
test "up_matches renders when no matches exist" do
|
||||
sign_in_owner
|
||||
wipe
|
||||
get :up_matches, params: { id: 1 }
|
||||
no_matches
|
||||
success
|
||||
end
|
||||
|
||||
test "redirect bracket if no matches" do
|
||||
|
||||
@@ -40,9 +40,9 @@ class UpMatchesCacheTest < ActionController::TestCase
|
||||
mat.reload
|
||||
movable_match = mat.queue2_match || mat.queue1_match
|
||||
assert movable_match, "Expected at least one queued match to move"
|
||||
mat.assign_match_to_queue!(movable_match, 4)
|
||||
|
||||
third_events = cache_events_for_up_matches do
|
||||
mat.assign_match_to_queue!(movable_match, 4)
|
||||
get :up_matches, params: { id: @tournament.id }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
@@ -25,6 +25,7 @@ class RandomSeedingTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "There are the same number of matches in the top half and bottom half of a double elimination tournament in round 1" do
|
||||
# This has to be an even number otherwise there will obviously be a discrepency
|
||||
create_double_elim_tournament_single_weight(18, "Regular Double Elimination 1-8")
|
||||
clean_up_original_seeds(@tournament)
|
||||
round_one_matches = @tournament.matches.reload.select{|m| m.round == 1}
|
||||
@@ -35,6 +36,7 @@ class RandomSeedingTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "There are the same number of matches in the top half and bottom half of a double elimination tournament in round 1 in a 6 man bracket" do
|
||||
# This has to be an even number otherwise there will obviously be a discrepency
|
||||
create_double_elim_tournament_single_weight(6, "Regular Double Elimination 1-8")
|
||||
clean_up_original_seeds(@tournament)
|
||||
round_one_matches = @tournament.matches.reload.select{|m| m.round == 1}
|
||||
@@ -52,4 +54,22 @@ class RandomSeedingTest < ActionDispatch::IntegrationTest
|
||||
assert round_one_matches.select{|m| m.w1.nil? and m.w2.nil? }.size == 0
|
||||
assert conso_round_one_matches.select{|m| m.loser1_name == "BYE" and m.loser2_name == "BYE" }.size == 0
|
||||
end
|
||||
|
||||
test "There are no double byes in a 64 person double elimination tournament in round 1" do
|
||||
create_double_elim_tournament_single_weight(33, "Regular Double Elimination 1-8")
|
||||
clean_up_original_seeds(@tournament)
|
||||
round_one_matches = @tournament.matches.reload.select{|m| m.round == 1}
|
||||
assert round_one_matches.select{|m| m.w1.nil? and m.w2.nil? }.size == 0
|
||||
end
|
||||
|
||||
test "There are the same number of matches in the top half and bottom half of a 64 person double elimination tournament in round 1" do
|
||||
# This has to be an even number otherwise there will obviously be a discrepency
|
||||
create_double_elim_tournament_single_weight(34, "Regular Double Elimination 1-8")
|
||||
clean_up_original_seeds(@tournament)
|
||||
round_one_matches = @tournament.matches.reload.select{|m| m.round == 1}
|
||||
# 64 man bracket there are 32 matches so top half is bracket_position_number 1-16 and bottom is 17-32
|
||||
round_one_top_half = round_one_matches.select{|m| !m.w1.nil? and !m.w2.nil? and m.bracket_position_number < 17}
|
||||
round_one_bottom_half = round_one_matches.select{|m| !m.w1.nil? and !m.w2.nil? and m.bracket_position_number > 16}
|
||||
assert round_one_top_half.size == round_one_bottom_half.size
|
||||
end
|
||||
end
|
||||
87
test/models/up_matches_broadcast_test.rb
Normal file
87
test/models/up_matches_broadcast_test.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
require "test_helper"
|
||||
|
||||
class UpMatchesBroadcastTest < ActiveSupport::TestCase
|
||||
test "tournament broadcaster emits replace action for up matches board" do
|
||||
tournament = tournaments(:one)
|
||||
stream = stream_name_for(tournament)
|
||||
clear_streams(stream)
|
||||
|
||||
Tournament.broadcast_up_matches_board(tournament.id)
|
||||
|
||||
assert_operator broadcasts_for(stream).size, :>, 0
|
||||
payload = broadcasts_for(stream).last
|
||||
assert_up_matches_replace_payload(payload)
|
||||
end
|
||||
|
||||
test "mat queue change broadcasts up matches board update" do
|
||||
tournament = tournaments(:one)
|
||||
mat = mats(:one)
|
||||
match = matches(:tournament_1_bout_2000)
|
||||
stream = stream_name_for(tournament)
|
||||
clear_streams(stream)
|
||||
|
||||
mat.update!(queue1: match.id)
|
||||
|
||||
assert_operator broadcasts_for(stream).size, :>, 0
|
||||
assert_up_matches_replace_payload(broadcasts_for(stream).last)
|
||||
end
|
||||
|
||||
test "match mat assignment change broadcasts up matches board update" do
|
||||
tournament = tournaments(:one)
|
||||
mat = mats(:one)
|
||||
match = matches(:tournament_1_bout_2001)
|
||||
stream = stream_name_for(tournament)
|
||||
clear_streams(stream)
|
||||
|
||||
match.update!(mat_id: mat.id)
|
||||
|
||||
assert_operator broadcasts_for(stream).size, :>, 0
|
||||
assert_up_matches_replace_payload(broadcasts_for(stream).last)
|
||||
end
|
||||
|
||||
test "mat update without queue slot changes does not broadcast up matches board update" do
|
||||
tournament = tournaments(:one)
|
||||
mat = mats(:one)
|
||||
stream = stream_name_for(tournament)
|
||||
clear_streams(stream)
|
||||
|
||||
mat.update!(name: "Mat One Renamed")
|
||||
|
||||
assert_equal 0, broadcasts_for(stream).size
|
||||
end
|
||||
|
||||
test "match update without mat_id change does not broadcast up matches board update" do
|
||||
tournament = tournaments(:one)
|
||||
match = matches(:tournament_1_bout_2001)
|
||||
stream = stream_name_for(tournament)
|
||||
clear_streams(stream)
|
||||
|
||||
match.update!(w1_stat: "Local stat change")
|
||||
|
||||
assert_equal 0, broadcasts_for(stream).size
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcasts_for(stream)
|
||||
ActionCable.server.pubsub.broadcasts(stream)
|
||||
end
|
||||
|
||||
def clear_streams(*streams)
|
||||
ActionCable.server.pubsub.clear
|
||||
streams.each do |stream|
|
||||
broadcasts_for(stream).clear
|
||||
end
|
||||
end
|
||||
|
||||
def stream_name_for(streamable)
|
||||
Turbo::StreamsChannel.send(:stream_name_from, [streamable])
|
||||
end
|
||||
|
||||
# Broadcast payloads may be JSON-escaped in test adapters, so assert semantic markers.
|
||||
def assert_up_matches_replace_payload(payload)
|
||||
assert_includes payload, "up_matches_board"
|
||||
assert_includes payload, "replace"
|
||||
assert_includes payload, "turbo-stream"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user